From 432efc3e8994de2f6c8b8f189dc43e8b6bf86d79 Mon Sep 17 00:00:00 2001 From: Lucas Welscher Date: Sun, 27 Oct 2024 20:27:32 +0100 Subject: [PATCH 01/13] Development: Upgrade markdown library to markdown-it (#9354) --- angular.json | 4 +- docs/user/markdown-support.rst | 10 +- package-lock.json | 221 ++++++++++-------- package.json | 15 +- ...rogramming-exercise-plant-uml.extension.ts | 106 ++++----- .../programming-exercise-task.extension.ts | 27 +-- ...gramming-exercise-instruction.component.ts | 4 +- .../short-answer-question-util.service.ts | 1 + src/main/webapp/app/index.d.ts | 9 +- .../ArtemisTextReplacementPlugin.ts | 24 ++ .../artemis-showdown-extension-wrapper.ts | 15 -- .../markdown-editor-monaco.component.html | 2 +- .../webapp/app/shared/markdown.service.ts | 10 +- .../shared/pipes/html-for-markdown.pipe.ts | 6 +- .../shared/util/markdown.conversion.util.ts | 93 +++++--- ...short-answer-question-util.service.spec.ts | 2 +- .../text-unit/text-unit.component.spec.ts | 2 +- .../posting-content-part.component.spec.ts | 2 +- .../spec/helpers/sample/problemStatement.json | 4 +- .../spec/service/markdown.service.spec.ts | 28 +++ 20 files changed, 322 insertions(+), 263 deletions(-) create mode 100644 src/main/webapp/app/shared/markdown-editor/extensions/ArtemisTextReplacementPlugin.ts delete mode 100644 src/main/webapp/app/shared/markdown-editor/extensions/artemis-showdown-extension-wrapper.ts diff --git a/angular.json b/angular.json index 008ac75d13bf..d9535f656cd2 100644 --- a/angular.json +++ b/angular.json @@ -44,9 +44,7 @@ "react-is", "rfdc", "shallowequal", - "showdown-highlight", - "showdown-katex", - "showdown", + "markdown-it-class", "smoothscroll-polyfill", "sockjs-client", "use-sync-external-store/shim", diff --git a/docs/user/markdown-support.rst b/docs/user/markdown-support.rst index c28d50fa2786..9ac5b6f5a56f 100644 --- a/docs/user/markdown-support.rst +++ b/docs/user/markdown-support.rst @@ -9,7 +9,7 @@ Markdown Support `Markdown `__ is an easy-to-read, easy-to-write syntax for formatting plain text. -A markdown playground can be found `here `__. +A markdown playground can be found `here `__. Artemis extends the basic `Markdown `__ syntax to support Artemis-specific features. This Artemis flavored Markdown is used to format text content across the platform using an integrated markdown editor. @@ -52,9 +52,9 @@ Markdown is also supported in the context of :ref:`communicating` Supported Syntax ^^^^^^^^^^^^^^^^ -The integrated markdown editor uses `Showdown `__. A quick description of the supported syntax can be found `here `__. +The integrated markdown editor uses `MarkdownIt `__. A quick description of the supported syntax can be found `here `__. -The following Showdown extensions are activated: +The following Plugins are activated: -- `Showdown Katex `__ to render LaTeX math and AsciiMath using KaTeX. -- `Showdown Highlight `__ for syntax highlighting in code blocks. +- `MarkdownIt Katex `__ to render LaTeX math and AsciiMath using KaTeX. +- `MarkdownIt HighlightJS `__ for syntax highlighting in code blocks. diff --git a/package-lock.json b/package-lock.json index 9ab71995dbff..74e3625253d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@swimlane/ngx-charts": "20.5.0", "@swimlane/ngx-graph": "8.4.0", "@vscode/codicons": "0.0.36", + "@vscode/markdown-it-katex": "1.1.0", "bootstrap": "5.3.3", "compare-versions": "6.1.1", "core-js": "3.38.1", @@ -54,6 +55,9 @@ "js-video-url-parser": "0.5.1", "jszip": "3.10.1", "lodash-es": "4.17.21", + "markdown-it": "14.1.0", + "markdown-it-class": "1.0.0", + "markdown-it-highlightjs": "4.2.0", "mobile-drag-drop": "3.0.0-rc.0", "monaco-editor": "0.52.0", "ngx-infinite-scroll": "18.0.0", @@ -62,15 +66,13 @@ "pdfjs-dist": "4.7.76", "posthog-js": "1.176.0", "rxjs": "7.8.1", - "showdown": "2.1.0", - "showdown-highlight": "3.1.0", - "showdown-katex": "0.6.0", "simple-statistics": "7.8.7", "smoothscroll-polyfill": "0.4.4", "sockjs-client": "1.6.1", "split.js": "1.6.5", "ts-cacheable": "1.0.10", "tslib": "2.8.0", + "turndown": "7.2.0", "uuid": "10.0.0", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -93,11 +95,12 @@ "@types/dompurify": "3.0.5", "@types/jest": "29.5.14", "@types/lodash-es": "4.17.12", + "@types/markdown-it": "14.1.2", "@types/node": "22.7.9", "@types/papaparse": "5.3.15", - "@types/showdown": "2.0.6", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", + "@types/turndown": "5.0.5", "@types/uuid": "10.0.0", "@typescript-eslint/eslint-plugin": "8.11.0", "@typescript-eslint/parser": "8.11.0", @@ -5262,6 +5265,12 @@ "node": ">=6" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -7325,6 +7334,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.12", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.12.tgz", @@ -7342,6 +7358,24 @@ "@types/lodash": "*" } }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -7466,13 +7500,6 @@ "@types/send": "*" } }, - "node_modules/@types/showdown": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/showdown/-/showdown-2.0.6.tgz", - "integrity": "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/smoothscroll-polyfill": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@types/smoothscroll-polyfill/-/smoothscroll-polyfill-0.3.4.tgz", @@ -7518,6 +7545,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/turndown": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz", + "integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", @@ -7774,6 +7808,15 @@ "integrity": "sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==", "license": "CC-BY-4.0" }, + "node_modules/@vscode/markdown-it-katex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@vscode/markdown-it-katex/-/markdown-it-katex-1.1.0.tgz", + "integrity": "sha512-9cF2eJpsJOEs2V1cCAoJW/boKz9GQQLvZhNvI030K90z6ZE9lRGc9hDVvKut8zdFO2ObjwylPXXXVYvTdP2O2Q==", + "license": "MIT", + "dependencies": { + "katex": "^0.16.4" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", @@ -8333,7 +8376,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -10986,7 +11028,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -12966,15 +13007,6 @@ "node": ">= 0.4" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, "node_modules/highlight.js": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.10.0.tgz", @@ -13038,17 +13070,6 @@ "integrity": "sha512-9SQg9oLQSAOZb8rO17mRNPkVB95QRh6iLY5J0Dbc/cgeoBT+XJBK/6XrQqfd+vxUVRjdctW+sfgYqgYzi0vg9g==", "license": "ISC" }, - "node_modules/html-encoder-decoder": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/html-encoder-decoder/-/html-encoder-decoder-1.3.10.tgz", - "integrity": "sha512-18SjgzQZ9U1mxb96rjcWgWMnTlEzNj2lU2wAU7OeUobdIWXTS6lOGc6419eLhMlX24sNQYDyQfgkSXWjyq/Ilg==", - "license": "MIT", - "dependencies": { - "he": "^1.1.0", - "iterate-object": "^1.3.2", - "regex-escape": "^3.4.2" - } - }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -13912,12 +13933,6 @@ "node": ">=8" } }, - "node_modules/iterate-object": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/iterate-object/-/iterate-object-1.3.4.tgz", - "integrity": "sha512-4dG1D1x/7g8PwHS9aK6QV5V94+ZvyP4+d19qDv43EzImmrndysIl4prmJ1hWWIGCqrZHyaHBm6BSEWHOLnpoNw==", - "license": "MIT" - }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -16321,6 +16336,15 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lint-staged": { "version": "15.2.10", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", @@ -16983,12 +17007,50 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-class": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-class/-/markdown-it-class-1.0.0.tgz", + "integrity": "sha512-CVDYqSgmErLAqInwWu8WmAR2nX6MMIBIt8LB6qg8DNldca9+aoC6ZyuY0lvBMsaTSHNFJRkcHVR1XjLw9nr9qQ==", + "license": "MIT" + }, + "node_modules/markdown-it-highlightjs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/markdown-it-highlightjs/-/markdown-it-highlightjs-4.2.0.tgz", + "integrity": "sha512-NC7pXE8KkOl6xWJVRNt8p6wgJVznXKsE0HgYGdk6DD2tn1l4L9f0ALf3VIoGVkotNU1uGQatSxfBF1zZPUMmuQ==", + "license": "Unlicense", + "dependencies": { + "highlight.js": "^11.9.0" + } + }, "node_modules/material-colors": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==", "license": "ISC" }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -19547,6 +19609,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -19898,12 +19969,6 @@ "@babel/runtime": "^7.8.4" } }, - "node_modules/regex-escape": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/regex-escape/-/regex-escape-3.4.10.tgz", - "integrity": "sha512-qEqf7uzW+iYcKNLMDFnMkghhQBnGdivT6KqVQyKsyjSWnoFyooXVnxrw9dtv3AFLnD6VBGXxtZGAQNFGFTnCqA==", - "license": "MIT" - }, "node_modules/regex-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.0.tgz", @@ -20800,57 +20865,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/showdown": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", - "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", - "license": "MIT", - "dependencies": { - "commander": "^9.0.0" - }, - "bin": { - "showdown": "bin/showdown.js" - }, - "funding": { - "type": "individual", - "url": "https://www.paypal.me/tiviesantos" - } - }, - "node_modules/showdown-highlight": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/showdown-highlight/-/showdown-highlight-3.1.0.tgz", - "integrity": "sha512-wrTxtE63L/bpW5A2Uy/AO1gblXnNHK/cDL6LszECOoCdMJKWTj0/4n4I/pmqub+3H3KCPVDDvtXpCArnT/heFA==", - "license": "MIT", - "dependencies": { - "highlight.js": "^11.5.0", - "html-encoder-decoder": "^1.3.9", - "showdown": "^2.0.3" - } - }, - "node_modules/showdown-katex": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/showdown-katex/-/showdown-katex-0.6.0.tgz", - "integrity": "sha512-eEOipJjqMxRJ+e69WlA7XENhFZzKhNl12csey0iLd4QbLzGF61+FBxNPhEZFz9wICYTJNfyqNgLSqmm8Uj0fGA==", - "license": "MIT", - "dependencies": { - "katex": "^0.10.0" - }, - "engines": { - "node": "*" - }, - "peerDependencies": { - "showdown": "^1.4.3" - } - }, - "node_modules/showdown/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -22244,6 +22258,15 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/turndown": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", + "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -22362,6 +22385,12 @@ "typescript-compare": "^0.0.2" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", diff --git a/package.json b/package.json index c195b3503caa..04ad311c7f9a 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@swimlane/ngx-charts": "20.5.0", "@swimlane/ngx-graph": "8.4.0", "@vscode/codicons": "0.0.36", + "@vscode/markdown-it-katex": "1.1.0", "bootstrap": "5.3.3", "compare-versions": "6.1.1", "core-js": "3.38.1", @@ -57,6 +58,9 @@ "js-video-url-parser": "0.5.1", "jszip": "3.10.1", "lodash-es": "4.17.21", + "markdown-it": "14.1.0", + "markdown-it-class": "1.0.0", + "markdown-it-highlightjs": "4.2.0", "mobile-drag-drop": "3.0.0-rc.0", "monaco-editor": "0.52.0", "ngx-infinite-scroll": "18.0.0", @@ -65,15 +69,13 @@ "pdfjs-dist": "4.7.76", "posthog-js": "1.176.0", "rxjs": "7.8.1", - "showdown": "2.1.0", - "showdown-highlight": "3.1.0", - "showdown-katex": "0.6.0", "simple-statistics": "7.8.7", "smoothscroll-polyfill": "0.4.4", "sockjs-client": "1.6.1", "split.js": "1.6.5", "ts-cacheable": "1.0.10", "tslib": "2.8.0", + "turndown": "7.2.0", "uuid": "10.0.0", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -102,13 +104,9 @@ }, "express": "5.0.1", "jsdom": "25.0.1", - "katex": "0.16.11", "postcss": "8.4.47", "rimraf": "6.0.1", "semver": "7.6.3", - "showdown-katex": { - "showdown": "2.1.0" - }, "tough-cookie": "5.0.0", "vite": "5.4.10", "webpack-dev-middleware": "7.4.2", @@ -134,11 +132,12 @@ "@types/dompurify": "3.0.5", "@types/jest": "29.5.14", "@types/lodash-es": "4.17.12", + "@types/markdown-it": "14.1.2", "@types/node": "22.7.9", "@types/papaparse": "5.3.15", - "@types/showdown": "2.0.6", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", + "@types/turndown": "5.0.5", "@types/uuid": "10.0.0", "@typescript-eslint/eslint-plugin": "8.11.0", "@typescript-eslint/parser": "8.11.0", diff --git a/src/main/webapp/app/exercises/programming/shared/instructions-render/extensions/programming-exercise-plant-uml.extension.ts b/src/main/webapp/app/exercises/programming/shared/instructions-render/extensions/programming-exercise-plant-uml.extension.ts index 2c0d4036369b..886520d929f4 100644 --- a/src/main/webapp/app/exercises/programming/shared/instructions-render/extensions/programming-exercise-plant-uml.extension.ts +++ b/src/main/webapp/app/exercises/programming/shared/instructions-render/extensions/programming-exercise-plant-uml.extension.ts @@ -1,20 +1,19 @@ import { Injectable } from '@angular/core'; import { ProgrammingExerciseTestCase } from 'app/entities/programming/programming-exercise-test-case.model'; +import { ArtemisTextReplacementPlugin } from 'app/shared/markdown-editor/extensions/ArtemisTextReplacementPlugin'; +import { escapeStringForUseInRegex } from 'app/shared/util/global.utils'; import { Subject } from 'rxjs'; import { tap } from 'rxjs/operators'; -import { escapeStringForUseInRegex } from 'app/shared/util/global.utils'; import { ProgrammingExerciseInstructionService, TestCaseState } from 'app/exercises/programming/shared/instructions-render/service/programming-exercise-instruction.service'; import { ProgrammingExercisePlantUmlService } from 'app/exercises/programming/shared/instructions-render/service/programming-exercise-plant-uml.service'; -import { ArtemisShowdownExtensionWrapper } from 'app/shared/markdown-editor/extensions/artemis-showdown-extension-wrapper'; import { Result } from 'app/entities/result.model'; -import { ShowdownExtension } from 'showdown'; import DOMPurify from 'dompurify'; // This regex is the same as in the server: ProgrammingExerciseTaskService.java const testsColorRegex = /testsColor\((\s*[^()\s]+(\([^()]*\))?)\)/g; @Injectable({ providedIn: 'root' }) -export class ProgrammingExercisePlantUmlExtensionWrapper implements ArtemisShowdownExtensionWrapper { +export class ProgrammingExercisePlantUmlExtensionWrapper extends ArtemisTextReplacementPlugin { private latestResult?: Result; private testCases?: ProgrammingExerciseTestCase[]; private injectableElementsFoundSubject = new Subject<() => void>(); @@ -25,7 +24,9 @@ export class ProgrammingExercisePlantUmlExtensionWrapper implements ArtemisShowd constructor( private programmingExerciseInstructionService: ProgrammingExerciseInstructionService, private plantUmlService: ProgrammingExercisePlantUmlService, - ) {} + ) { + super(); + } /** * Sets latest result according to parameter. @@ -67,7 +68,6 @@ export class ProgrammingExercisePlantUmlExtensionWrapper implements ArtemisShowd } /** - * Creates and returns an extension to current exercise. * The extension provides a custom rendering mechanism for embedded plantUml diagrams. * The mechanism works as follows: * 1) Find (multiple) embedded plantUml diagrams based on a regex (startuml, enduml). @@ -76,55 +76,49 @@ export class ProgrammingExercisePlantUmlExtensionWrapper implements ArtemisShowd * 4) Send the plantUml content to the server for rendering a svg (the result will be cached for performance reasons) * 5) Inject the computed svg for the plantUml (from the server) into the plantUml div container based on the unique placeholder id (see step 2) */ - getExtension() { - const extension: ShowdownExtension = { - type: 'lang', - filter: (text: string) => { - const idPlaceholder = '%idPlaceholder%'; - // E.g. [task][Implement BubbleSort](testBubbleSort) - const plantUmlRegex = /@startuml([^@]*)@enduml/g; - // E.g. Implement BubbleSort, testBubbleSort - const plantUmlContainer = `
`; - // Replace test status markers. - const plantUmls = text.match(plantUmlRegex) ?? []; - // Assign unique ids to uml data structure at the beginning. - const plantUmlsIndexed = plantUmls.map((plantUml) => { - const nextIndex = this.plantUmlIndex; - // increase the global unique index so that the next plantUml gets a unique global id - this.plantUmlIndex++; - return { plantUmlId: nextIndex, plantUml }; - }); - // custom markdown to html rendering: replace the plantUml in the markdown with a simple
container with a unique id placeholder - // with the global unique id so that we can find the plantUml later on, when it was rendered, and then inject the 'actual' inner html (actually a svg image) - const replacedText = plantUmlsIndexed.reduce((acc: string, umlIndexed: { plantUmlId: number; plantUml: string }): string => { - return acc.replace(new RegExp(escapeStringForUseInRegex(umlIndexed.plantUml), 'g'), plantUmlContainer.replace(idPlaceholder, umlIndexed.plantUmlId.toString())); - }, text); - // before we send the plantUml to the server for rendering, we need to inject the current test status so that the colors can be adapted - // (green == implemented, red == not yet implemented, grey == unknown) - const plantUmlsValidated = plantUmlsIndexed.map((plantUmlIndexed: { plantUmlId: number; plantUml: string }) => { - plantUmlIndexed.plantUml = plantUmlIndexed.plantUml.replace(testsColorRegex, (match: any, capture: string) => { - const tests = this.programmingExerciseInstructionService.convertTestListToIds(capture, this.testCases); - const { testCaseState } = this.programmingExerciseInstructionService.testStatusForTask(tests, this.latestResult); - switch (testCaseState) { - case TestCaseState.SUCCESS: - return 'green'; - case TestCaseState.FAIL: - return 'red'; - default: - return 'grey'; - } - }); - return plantUmlIndexed; - }); - // send the adapted plantUml to the server for rendering and inject the result into the html DOM based on the unique plantUml id - this.injectableElementsFoundSubject.next(() => { - plantUmlsValidated.forEach((plantUmlIndexed: { plantUmlId: number; plantUml: string }) => { - this.loadAndInjectPlantUml(plantUmlIndexed.plantUml, plantUmlIndexed.plantUmlId); - }); - }); - return replacedText; - }, - }; - return extension; + replaceText(text: string): string { + const idPlaceholder = '%idPlaceholder%'; + // E.g. [task][Implement BubbleSort](testBubbleSort) + const plantUmlRegex = /@startuml([^@]*)@enduml/g; + // E.g. Implement BubbleSort, testBubbleSort + const plantUmlContainer = `
`; + // Replace test status markers. + const plantUmls = text.match(plantUmlRegex) ?? []; + // Assign unique ids to uml data structure at the beginning. + const plantUmlsIndexed = plantUmls.map((plantUml) => { + const nextIndex = this.plantUmlIndex; + // increase the global unique index so that the next plantUml gets a unique global id + this.plantUmlIndex++; + return { plantUmlId: nextIndex, plantUml }; + }); + // custom markdown to html rendering: replace the plantUml in the markdown with a simple
container with a unique id placeholder + // with the global unique id so that we can find the plantUml later on, when it was rendered, and then inject the 'actual' inner html (actually a svg image) + const replacedText = plantUmlsIndexed.reduce((acc: string, umlIndexed: { plantUmlId: number; plantUml: string }): string => { + return acc.replace(new RegExp(escapeStringForUseInRegex(umlIndexed.plantUml), 'g'), plantUmlContainer.replace(idPlaceholder, umlIndexed.plantUmlId.toString())); + }, text); + // before we send the plantUml to the server for rendering, we need to inject the current test status so that the colors can be adapted + // (green == implemented, red == not yet implemented, grey == unknown) + const plantUmlsValidated = plantUmlsIndexed.map((plantUmlIndexed: { plantUmlId: number; plantUml: string }) => { + plantUmlIndexed.plantUml = plantUmlIndexed.plantUml.replace(testsColorRegex, (match: string, capture: string) => { + const tests = this.programmingExerciseInstructionService.convertTestListToIds(capture, this.testCases); + const { testCaseState } = this.programmingExerciseInstructionService.testStatusForTask(tests, this.latestResult); + switch (testCaseState) { + case TestCaseState.SUCCESS: + return 'green'; + case TestCaseState.FAIL: + return 'red'; + default: + return 'grey'; + } + }); + return plantUmlIndexed; + }); + // send the adapted plantUml to the server for rendering and inject the result into the html DOM based on the unique plantUml id + this.injectableElementsFoundSubject.next(() => { + plantUmlsValidated.forEach((plantUmlIndexed: { plantUmlId: number; plantUml: string }) => { + this.loadAndInjectPlantUml(plantUmlIndexed.plantUml, plantUmlIndexed.plantUmlId); + }); + }); + return replacedText; } } diff --git a/src/main/webapp/app/exercises/programming/shared/instructions-render/extensions/programming-exercise-task.extension.ts b/src/main/webapp/app/exercises/programming/shared/instructions-render/extensions/programming-exercise-task.extension.ts index 5e7b54444a14..c6cbc4f1a2a2 100644 --- a/src/main/webapp/app/exercises/programming/shared/instructions-render/extensions/programming-exercise-task.extension.ts +++ b/src/main/webapp/app/exercises/programming/shared/instructions-render/extensions/programming-exercise-task.extension.ts @@ -1,8 +1,7 @@ import { Injectable, ViewContainerRef } from '@angular/core'; import { TaskArrayWithExercise } from 'app/exercises/programming/shared/instructions-render/task/programming-exercise-task.model'; -import { ArtemisShowdownExtensionWrapper } from 'app/shared/markdown-editor/extensions/artemis-showdown-extension-wrapper'; +import { ArtemisTextReplacementPlugin } from 'app/shared/markdown-editor/extensions/ArtemisTextReplacementPlugin'; import { Observable, Subject } from 'rxjs'; -import { ShowdownExtension } from 'showdown'; /** * Regular expression for finding tasks. @@ -18,15 +17,12 @@ import { ShowdownExtension } from 'showdown'; export const taskRegex = /\[task]\[([^[\]]+)]\(((?:[^(),]+(?:\([^()]*\)[^(),]*)?(?:,[^(),]+(?:\([^()]*\)[^(),]*)?)*)?)\)/g; @Injectable({ providedIn: 'root' }) -export class ProgrammingExerciseTaskExtensionWrapper implements ArtemisShowdownExtensionWrapper { +export class ProgrammingExerciseTaskExtensionWrapper extends ArtemisTextReplacementPlugin { // We don't have a provider for ViewContainerRef, so we pass it from ProgrammingExerciseInstructionComponent viewContainerRef: ViewContainerRef; private testsForTaskSubject = new Subject(); private injectableElementsFoundSubject = new Subject<() => void>(); - - constructor() {} - /** * Subscribes to injectableElementsFoundSubject. */ @@ -35,23 +31,12 @@ export class ProgrammingExerciseTaskExtensionWrapper implements ArtemisShowdownE } /** - * Creates and returns an extension to current exercise. - * The task regex is coupled to the value used in ProgrammingExerciseTaskService in the server and - * `TaskCommand` in the client + * The task regex is coupled to the value used in ProgrammingExerciseTaskService in the server + * and `TaskCommand` in the client * If you change the regex, make sure to change it in all places! */ - getExtension() { - const extension: ShowdownExtension = { - type: 'lang', - filter: (problemStatement: string) => { - return this.createTasks(problemStatement); - }, - }; - return extension; - } - - public createTasks(problemStatement: string): string { - return problemStatement.replace(taskRegex, (match) => { + replaceText(text: string): string { + return text.replace(taskRegex, (match) => { return this.escapeTaskSpecialCharactersForMarkdown(match); }); } diff --git a/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts b/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts index b0bd830496a9..9eac396fa2bc 100644 --- a/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts +++ b/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts @@ -16,7 +16,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { ThemeService } from 'app/core/theme/theme.service'; import { ProgrammingExerciseTestCase } from 'app/entities/programming/programming-exercise-test-case.model'; import { ProgrammingExerciseGradingService } from 'app/exercises/programming/manage/services/programming-exercise-grading.service'; -import { ShowdownExtension } from 'showdown'; +import type { PluginSimple } from 'markdown-it'; import { catchError, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; import { Observable, Subscription, merge, of } from 'rxjs'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; @@ -80,7 +80,7 @@ export class ProgrammingExerciseInstructionComponent implements OnChanges, OnDes public renderedMarkdown: SafeHtml; private injectableContentForMarkdownCallbacks: Array<() => void> = []; - markdownExtensions: ShowdownExtension[]; + markdownExtensions: PluginSimple[]; private injectableContentFoundSubscription: Subscription; private tasksSubscription: Subscription; private generateHtmlSubscription: Subscription; diff --git a/src/main/webapp/app/exercises/quiz/shared/short-answer-question-util.service.ts b/src/main/webapp/app/exercises/quiz/shared/short-answer-question-util.service.ts index c9cf7bc98076..6e6aac7e24eb 100644 --- a/src/main/webapp/app/exercises/quiz/shared/short-answer-question-util.service.ts +++ b/src/main/webapp/app/exercises/quiz/shared/short-answer-question-util.service.ts @@ -373,6 +373,7 @@ export class ShortAnswerQuestionUtil { if (firstWord === '') { continue; } + const firstWordIndex = element.indexOf(firstWord); const whitespace = ' '.repeat(this.getIndentation(originalTextParts[i][0]).length); formattedTextParts[i][0] = [element.substring(0, firstWordIndex), whitespace, element.substring(firstWordIndex).trim()].join(''); diff --git a/src/main/webapp/app/index.d.ts b/src/main/webapp/app/index.d.ts index 44ce332f5e6b..41fc280ef9ff 100644 --- a/src/main/webapp/app/index.d.ts +++ b/src/main/webapp/app/index.d.ts @@ -1,9 +1,4 @@ -declare module 'showdown-katex' { - const main: () => ShowDownExtension; - export = main; -} - -declare module 'showdown-highlight' { - const main: ({ pre: boolean }) => ShowDownExtension; +declare module 'markdown-it-class' { + const main: (md: MarkdownIt) => void; export = main; } diff --git a/src/main/webapp/app/shared/markdown-editor/extensions/ArtemisTextReplacementPlugin.ts b/src/main/webapp/app/shared/markdown-editor/extensions/ArtemisTextReplacementPlugin.ts new file mode 100644 index 000000000000..0a913251fc6d --- /dev/null +++ b/src/main/webapp/app/shared/markdown-editor/extensions/ArtemisTextReplacementPlugin.ts @@ -0,0 +1,24 @@ +import type MarkdownIt from 'markdown-it'; +import type { PluginSimple } from 'markdown-it'; + +/** + * Markdown-It plugin that allows replacing text in the raw markdown before tokenizing. + * See more about Markdown-It plugins here: https://github.com/markdown-it/markdown-it/tree/master/docs + */ +export abstract class ArtemisTextReplacementPlugin { + getExtension(): PluginSimple { + return (md: MarkdownIt): void => { + md.core.ruler.before('normalize', 'artemis_text_replacement', (state) => { + // Perform the replacement on the raw markdown text + state.src = this.replaceText(state.src); + }); + }; + } + + /** + * Performs text replacement on the raw markdown before parsing. + * @param text The raw markdown text. + * @returns The modified markdown text after replacements. + */ + abstract replaceText(text: string): string; +} diff --git a/src/main/webapp/app/shared/markdown-editor/extensions/artemis-showdown-extension-wrapper.ts b/src/main/webapp/app/shared/markdown-editor/extensions/artemis-showdown-extension-wrapper.ts deleted file mode 100644 index ce716b886c4e..000000000000 --- a/src/main/webapp/app/shared/markdown-editor/extensions/artemis-showdown-extension-wrapper.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ShowdownExtension } from 'showdown'; -import { Observable } from 'rxjs'; - -/** - * The idea of this interface is to provide more information for an extension. - * By implementing the interface, the extension can use data that is in the closure of the class (e.g. this.latestResult). - * 1) The component that uses the extension can request it from the wrapper class by using getExtension. - * 2) In some cases it might also be necessary to inject content after the html is loaded, as async data fetching is necessary. - * Therefore, the component can subscribe for injectable elements. - * - */ -export interface ArtemisShowdownExtensionWrapper { - getExtension: () => ShowdownExtension; - subscribeForInjectableElementsFound: () => Observable<() => void>; -} diff --git a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html index f34057d6ca0a..9b9c54d262fb 100644 --- a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html +++ b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html @@ -40,7 +40,7 @@ > diff --git a/src/main/webapp/app/shared/markdown.service.ts b/src/main/webapp/app/shared/markdown.service.ts index 646b03cb1b81..24e2589c616a 100644 --- a/src/main/webapp/app/shared/markdown.service.ts +++ b/src/main/webapp/app/shared/markdown.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; -import { addCSSClass, htmlForMarkdown } from 'app/shared/util/markdown.conversion.util'; -import showdown from 'showdown'; +import { htmlForMarkdown } from 'app/shared/util/markdown.conversion.util'; +import type { PluginSimple } from 'markdown-it'; @Injectable({ providedIn: 'root' }) export class ArtemisMarkdownService { @@ -11,21 +11,21 @@ export class ArtemisMarkdownService { * Converts markdown into html, sanitizes it and then declares it as safe to bypass further security. * * @param {string} markdownText the original markdown text - * @param extensions to use for markdown parsing + * @param {PluginSimple[]} extensions to use for markdown parsing * @param {string[]} allowedHtmlTags to allow during sanitization * @param {string[]} allowedHtmlAttributes to allow during sanitization * @returns {string} the resulting html as a SafeHtml object that can be inserted into the angular template */ safeHtmlForMarkdown( markdownText?: string, - extensions: showdown.ShowdownExtension[] = [], + extensions: PluginSimple[] = [], allowedHtmlTags: string[] | undefined = undefined, allowedHtmlAttributes: string[] | undefined = undefined, ): SafeHtml { if (!markdownText || markdownText === '') { return ''; } - const convertedString = htmlForMarkdown(markdownText, [...extensions, ...addCSSClass], allowedHtmlTags, allowedHtmlAttributes); + const convertedString = htmlForMarkdown(markdownText, extensions, allowedHtmlTags, allowedHtmlAttributes); return this.sanitizer.bypassSecurityTrustHtml(convertedString); } diff --git a/src/main/webapp/app/shared/pipes/html-for-markdown.pipe.ts b/src/main/webapp/app/shared/pipes/html-for-markdown.pipe.ts index 610faf20c0ca..660a5c0971dc 100644 --- a/src/main/webapp/app/shared/pipes/html-for-markdown.pipe.ts +++ b/src/main/webapp/app/shared/pipes/html-for-markdown.pipe.ts @@ -1,7 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { ShowdownExtension } from 'showdown'; import { SafeHtml } from '@angular/platform-browser'; import { ArtemisMarkdownService } from 'app/shared/markdown.service'; +import type { PluginSimple } from 'markdown-it'; @Pipe({ name: 'htmlForMarkdown', @@ -12,14 +12,14 @@ export class HtmlForMarkdownPipe implements PipeTransform { /** * Converts markdown into html, sanitizes it and then declares it as safe to bypass further security. * @param {string} markdown the original markdown text - * @param {ShowdownExtension[]} extensions to use for markdown parsing + * @param {PluginSimple[]} extensions to use for markdown parsing * @param {string[]} allowedHtmlTags to allow during sanitization * @param {string[]} allowedHtmlAttributes to allow during sanitization * @returns {string} the resulting html as a SafeHtml object that can be inserted into the angular template */ transform( markdown?: string, - extensions: ShowdownExtension[] = [], + extensions: PluginSimple[] = [], allowedHtmlTags: string[] | undefined = undefined, allowedHtmlAttributes: string[] | undefined = undefined, ): SafeHtml { diff --git a/src/main/webapp/app/shared/util/markdown.conversion.util.ts b/src/main/webapp/app/shared/util/markdown.conversion.util.ts index 658574db088b..d5166b70d690 100644 --- a/src/main/webapp/app/shared/util/markdown.conversion.util.ts +++ b/src/main/webapp/app/shared/util/markdown.conversion.util.ts @@ -1,24 +1,41 @@ -import showdown from 'showdown'; -import showdownKatex from 'showdown-katex'; -import showdownHighlight from 'showdown-highlight'; +import { ArtemisTextReplacementPlugin } from 'app/shared/markdown-editor/extensions/ArtemisTextReplacementPlugin'; import DOMPurify, { Config } from 'dompurify'; +import type { PluginSimple } from 'markdown-it'; +import markdownIt from 'markdown-it'; +import markdownItClass from 'markdown-it-class'; +import markdownItKatex from '@vscode/markdown-it-katex'; +import markdownItHighlightjs from 'markdown-it-highlightjs'; +import TurndownService from 'turndown'; /** - * showdown will add the classes to the converted html - * see: https://github.com/showdownjs/showdown/wiki/Add-default-classes-for-each-HTML-element + * Add these classes to the converted html. */ const classMap: { [key: string]: string } = { table: 'table', }; -/** - * extension to add css classes to html tags - * see: https://github.com/showdownjs/showdown/wiki/Add-default-classes-for-each-HTML-element - */ -export const addCSSClass = Object.keys(classMap).map((key) => ({ - type: 'output', - regex: new RegExp(`<${key}(.*)>`, 'g'), - replace: `<${key} class="${classMap[key]}" $1>`, -})); + +// An inline math formula has some other characters before or after the formula and uses $$ as delimiters +const inlineFormulaRegex = /(?:.+\$\$[^\$]+\$\$)|(?:\$\$[^\$]+\$\$.+)/g; + +class FormulaCompatibilityPlugin extends ArtemisTextReplacementPlugin { + replaceText(text: string): string { + return text + .split('\n') + .map((line) => { + if (line.match(inlineFormulaRegex)) { + line = line.replace(/\$\$/g, '$'); + } + if (line.includes('\\\\begin') || line.includes('\\\\end')) { + line = line.replaceAll('\\\\begin', '\\begin').replaceAll('\\\\end', '\\end'); + } + return line; + }) + .join('\n'); + } +} +const formulaCompatibilityPlugin = new FormulaCompatibilityPlugin(); + +const turndownService = new TurndownService(); /** * Converts markdown into html (string) and sanitizes it. Does NOT declare it as safe to bypass further security @@ -32,24 +49,37 @@ export const addCSSClass = Object.keys(classMap).map((key) => ({ */ export function htmlForMarkdown( markdownText?: string, - extensions: showdown.ShowdownExtension[] = [], + extensions: PluginSimple[] = [], allowedHtmlTags: string[] | undefined = undefined, allowedHtmlAttributes: string[] | undefined = undefined, ): string { if (!markdownText || markdownText === '') { return ''; } - const converter = new showdown.Converter({ - parseImgDimensions: true, - headerLevelStart: 3, - simplifiedAutoLink: true, - strikethrough: true, - tables: true, - openLinksInNewWindow: true, - backslashEscapesHTMLTags: true, - extensions: [...extensions, showdownKatex(), showdownHighlight({ pre: true }), ...addCSSClass], + + const md = markdownIt({ + html: true, + linkify: true, + breaks: false, // Avoid line breaks after tasks }); - const html = converter.makeHtml(markdownText); + for (const extension of extensions) { + md.use(extension); + } + + // Add default extensions (Code Highlight, Latex) + md.use(markdownItHighlightjs) + .use(formulaCompatibilityPlugin.getExtension()) + .use(markdownItKatex, { + enableMathInlineInHtml: true, + }) + .use(markdownItClass, classMap); + let markdownRender = md.render(markdownText); + if (markdownRender.endsWith('\n')) { + // Keep legacy behavior from showdown where the output does not end with \n. + // This is needed because e.g. for quiz questions, we render the markdown in multiple small parts and then concatenate them. + markdownRender = markdownRender.slice(0, -1); + } + const purifyParameters = {} as Config; // Prevents sanitizer from deleting id purifyParameters['ADD_TAGS'] = ['testid']; @@ -59,18 +89,9 @@ export function htmlForMarkdown( if (allowedHtmlAttributes) { purifyParameters['ALLOWED_ATTR'] = allowedHtmlAttributes; } - return DOMPurify.sanitize(html, purifyParameters) as string; + return DOMPurify.sanitize(markdownRender, purifyParameters) as string; } export function markdownForHtml(htmlText: string): string { - const converter = new showdown.Converter({ - parseImgDimensions: true, - headerLevelStart: 3, - simplifiedAutoLink: true, - strikethrough: true, - tables: true, - openLinksInNewWindow: true, - backslashEscapesHTMLTags: true, - }); - return converter.makeMarkdown(htmlText); + return turndownService.turndown(htmlText); } diff --git a/src/test/javascript/spec/component/exercises/quiz/short-answer-question-util.service.spec.ts b/src/test/javascript/spec/component/exercises/quiz/short-answer-question-util.service.spec.ts index da7e4949bbb3..47aeba9f7fd9 100644 --- a/src/test/javascript/spec/component/exercises/quiz/short-answer-question-util.service.spec.ts +++ b/src/test/javascript/spec/component/exercises/quiz/short-answer-question-util.service.spec.ts @@ -216,7 +216,7 @@ describe('ShortAnswerQuestionUtil', () => { const originalTextParts2 = [['`random code`'], ['` some more code`', '[-spot 1]'], ['`last code paragraph`']]; const formattedTextParts2 = [ ['

random code

'], - ['

    some more code

', '

[-spot 1]

'], + ['

    some more code

', '

[-spot 1]

'], ['

last code paragraph

'], ]; expect(service.transformTextPartsIntoHTML(originalTextParts2)).toEqual(formattedTextParts2); diff --git a/src/test/javascript/spec/component/lecture-unit/text-unit/text-unit.component.spec.ts b/src/test/javascript/spec/component/lecture-unit/text-unit/text-unit.component.spec.ts index dc8ffa8669af..3326a8e91514 100644 --- a/src/test/javascript/spec/component/lecture-unit/text-unit/text-unit.component.spec.ts +++ b/src/test/javascript/spec/component/lecture-unit/text-unit/text-unit.component.spec.ts @@ -27,7 +27,7 @@ describe('TextUnitComponent', () => { visibleToStudents: true, }; - const exampleHtml = '

Sample Markdown

'; + const exampleHtml = '

Sample Markdown

'; beforeEach(async () => { await TestBed.configureTestingModule({ diff --git a/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts b/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts index 81e073bf43a6..424567dd6518 100644 --- a/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts @@ -94,7 +94,7 @@ describe('PostingContentPartComponent', () => { expect(markdownRenderedTexts).toHaveLength(2); // check that the paragraph right before the reference and the paragraph right after have the class `inline-paragraph` expect(markdownRenderedTexts![0].innerHTML).toInclude('

Be aware

'); - expect(markdownRenderedTexts![0].innerHTML).toInclude('

I want to reference the following Post

'); // last paragraph before reference + expect(markdownRenderedTexts![0].innerHTML).toInclude('

I want to reference the following Post

'); // last paragraph before reference expect(markdownRenderedTexts![1].innerHTML).toInclude('

in my content,

'); // first paragraph after reference expect(markdownRenderedTexts![1].innerHTML).toInclude('

does it actually work?

'); diff --git a/src/test/javascript/spec/helpers/sample/problemStatement.json b/src/test/javascript/spec/helpers/sample/problemStatement.json index 125a7c25d5c9..6a9297f35013 100644 --- a/src/test/javascript/spec/helpers/sample/problemStatement.json +++ b/src/test/javascript/spec/helpers/sample/problemStatement.json @@ -7,8 +7,8 @@ "problemStatementBothFailedRendered": "
    \n
  1. Implement Bubble Sort: artemisApp.editor.testStatusLabels.noResult
    \nImplement the method performSort(List<Date>) in the class BubbleSort. Make sure to follow the Bubble Sort algorithm exactly.
  2. \n
  3. Implement Merge Sort: artemisApp.editor.testStatusLabels.noResult
    \nImplement the method performSort(List<Date>) in the class MergeSort. Make sure to follow the Merge Sort algorithm exactly.
  4. \n
\n", "problemStatementBothFailedHtml": "
    \n
  1. Implement Bubble Sort: artemisApp.editor.testStatusLabels.testFailing
    \nImplement the method performSort(List<Date>) in the class BubbleSort. Make sure to follow the Bubble Sort algorithm exactly.
  2. \n
  3. Implement Merge Sort: artemisApp.editor.testStatusLabels.testPassing
    \nImplement the method performSort(List<Date>) in the class MergeSort. Make sure to follow the Merge Sort algorithm exactly.
  4. \n
\n", "problemStatementBubbleSortFailsRendered": "
    \n
  1. Implement Bubble Sort: artemisApp.editor.testStatusLabels.noResult
    \nImplement the method performSort(List<Date>) in the class BubbleSort. Make sure to follow the Bubble Sort algorithm exactly.
  2. \n
  3. Implement Merge Sort: artemisApp.editor.testStatusLabels.noResult
    \nImplement the method performSort(List<Date>) in the class MergeSort. Make sure to follow the Merge Sort algorithm exactly.
  4. \n
\n", - "problemStatementBubbleSortNotExecutedHtml": "
    \n
  1. Implement Bubble SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":0}]
    \nImplement the method performSort(List<Date>) in the class BubbleSort. Make sure to follow the Bubble Sort algorithm exactly.
  2. \n
  3. Implement Merge SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":1}]
    \nImplement the method performSort(List<Date>) in the class MergeSort. Make sure to follow the Merge Sort algorithm exactly.
  4. \n
", + "problemStatementBubbleSortNotExecutedHtml": "
    \n
  1. Implement Bubble SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":0}]
    \nImplement the method performSort(List<Date>) in the class BubbleSort. Make sure to follow the Bubble Sort algorithm exactly.
  2. \n
  3. Implement Merge SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":1}]
    \nImplement the method performSort(List<Date>) in the class MergeSort. Make sure to follow the Merge Sort algorithm exactly.
  4. \n
", "problemStatementEmptySecondTask": "1. [task][Bubble Sort](1) \n Implement the method. \n 2. [task][Merge Sort]() \n Implement the method.", - "problemStatementEmptySecondTaskNotExecutedHtml": "
    \n
  1. Bubble SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":1}]
    \nImplement the method.
  2. \n
  3. Merge SortartemisApp.editor.testStatusLabels.noTests
    \nImplement the method.
  4. \n
", + "problemStatementEmptySecondTaskNotExecutedHtml": "
    \n
  1. Bubble SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":1}]
    \nImplement the method.
  2. \n
  3. Merge SortartemisApp.editor.testStatusLabels.noTests
    \nImplement the method.
  4. \n
", "problemStatementPlantUMLWithTest": "@startuml\nclass Policy {\n1)>+configure()\n2)>+testWithParenthesis()}\n@enduml" } diff --git a/src/test/javascript/spec/service/markdown.service.spec.ts b/src/test/javascript/spec/service/markdown.service.spec.ts index 6473a2b6405d..d33f0113eea9 100644 --- a/src/test/javascript/spec/service/markdown.service.spec.ts +++ b/src/test/javascript/spec/service/markdown.service.spec.ts @@ -108,4 +108,32 @@ describe('Markdown Service', () => { const safeMarkdownWithoutExtras = htmlForMarkdown(markdownString, [], [], []); expect(safeMarkdownWithoutExtras).toBe('Will this render blue?'); }); + + describe('formulaCompatibilityPlugin', () => { + it.each(['This is a formula $$E=mc^2$$ in text.', '$$a_1$$ formula at front', 'formula at back $$a_2$$'])('converts block formulas to inline formulas', (input) => { + const result = htmlForMarkdown(input); + expect(result).toContain(''); + expect(result).not.toContain('class="katex-block"'); + }); + + it('does not convert block formulas without surrounding text', () => { + const result = htmlForMarkdown('$$E=mc^2$$'); + expect(result).toContain('class="katex-block"'); + expect(result).toContain('display="block"'); + }); + + it('converts double-backslash LaTeX begin and end tags', () => { + const result = htmlForMarkdown('Here is some LaTeX: $$\\\\begin{equation}a^2 + b^2 = c^2\\\\end{equation}$$\n'); + expect(result).toContain(''); + expect(result).toContain('class="katex-html"'); + }); + + it('handles multiple formulas in the same text', () => { + const result = htmlForMarkdown('First formula $$a^2 + b^2 = c^2$$ and second formula $$E=mc^2$$.'); + const formulaCount = (result.match(/class="katex"/g) || []).length; + expect(formulaCount).toBe(2); + expect(result).not.toContain('class="katex-block"'); + expect(result).not.toContain('display="block"'); + }); + }); }); From 7346de1a79695d6b30949c17780c881c3c7e3b43 Mon Sep 17 00:00:00 2001 From: Florian Glombik <63976129+florian-glombik@users.noreply.github.com> Date: Sun, 27 Oct 2024 20:27:51 +0100 Subject: [PATCH 02/13] Development: Fix DOM event name conflicts (#9589) --- .../assessment-header.component.html | 6 +++--- .../assessment-header.component.ts | 10 ++++------ .../assessment-layout.component.html | 4 ++-- .../assessment-layout.component.ts | 6 ++---- .../complaints-student-view.component.html | 2 +- .../complaints/form/complaints-form.component.ts | 5 ++--- .../assess/file-upload-assessment.component.html | 4 ++-- .../file-upload-exercise-update.component.html | 2 +- .../modeling-assessment-editor.component.html | 4 ++-- .../modeling-exercise-update.component.html | 2 +- ...tor-tutor-assessment-container.component.html | 4 ++-- .../programming-exercise-update.component.html | 2 +- .../text-submission-assessment.component.html | 4 ++-- .../textblock-assessment-card.component.html | 2 +- .../textblock-feedback-editor.component.ts | 4 ++-- .../text-exercise-update.component.html | 2 +- .../forms/form-footer/form-footer.component.html | 2 +- .../forms/form-footer/form-footer.component.ts | 4 ++-- .../assessment-header.component.spec.ts | 16 ++++++++-------- .../complaints/complaints-form.component.spec.ts | 8 ++++---- 20 files changed, 44 insertions(+), 49 deletions(-) diff --git a/src/main/webapp/app/assessment/assessment-header/assessment-header.component.html b/src/main/webapp/app/assessment/assessment-header/assessment-header.component.html index fbf4de107932..47c600031279 100644 --- a/src/main/webapp/app/assessment/assessment-header/assessment-header.component.html +++ b/src/main/webapp/app/assessment/assessment-header/assessment-header.component.html @@ -115,7 +115,7 @@

diff --git a/src/main/webapp/app/forms/form-footer/form-footer.component.ts b/src/main/webapp/app/forms/form-footer/form-footer.component.ts index 378422878b70..bb2192728381 100644 --- a/src/main/webapp/app/forms/form-footer/form-footer.component.ts +++ b/src/main/webapp/app/forms/form-footer/form-footer.component.ts @@ -10,8 +10,8 @@ import { ButtonSize } from 'app/shared/components/button.component'; }) export class FormFooterComponent { @Output() save = new EventEmitter(); - // eslint-disable-next-line @angular-eslint/no-output-native - @Output() cancel = new EventEmitter(); + + @Output() onCancel = new EventEmitter(); @Input() isSaving: boolean = false; @Input() isDisabled: boolean = false; diff --git a/src/test/javascript/spec/component/assessment-shared/assessment-header.component.spec.ts b/src/test/javascript/spec/component/assessment-shared/assessment-header.component.spec.ts index a85e5e47bc16..aa0073961211 100644 --- a/src/test/javascript/spec/component/assessment-shared/assessment-header.component.spec.ts +++ b/src/test/javascript/spec/component/assessment-shared/assessment-header.component.spec.ts @@ -159,14 +159,14 @@ describe('AssessmentHeaderComponent', () => { saveButtonSpan.nativeElement.click(); expect(component.save.emit).toHaveBeenCalledOnce(); - jest.spyOn(component.submit, 'emit'); + jest.spyOn(component.onSubmit, 'emit'); submitButtonSpan.nativeElement.click(); - expect(component.submit.emit).toHaveBeenCalledOnce(); + expect(component.onSubmit.emit).toHaveBeenCalledOnce(); const cancelButtonSpan = fixture.debugElement.query(By.css('[jhiTranslate$=cancel]')); - jest.spyOn(component.cancel, 'emit'); + jest.spyOn(component.onCancel, 'emit'); cancelButtonSpan.nativeElement.click(); - expect(component.cancel.emit).toHaveBeenCalledOnce(); + expect(component.onCancel.emit).toHaveBeenCalledOnce(); }); it('should show override button when result is present', () => { @@ -189,9 +189,9 @@ describe('AssessmentHeaderComponent', () => { overrideAssessmentButtonSpan = fixture.debugElement.query(By.css('[jhiTranslate$=overrideAssessment]')); expect(overrideAssessmentButtonSpan).toBeTruthy(); - jest.spyOn(component.submit, 'emit'); + jest.spyOn(component.onSubmit, 'emit'); overrideAssessmentButtonSpan.nativeElement.click(); - expect(component.submit.emit).toHaveBeenCalledOnce(); + expect(component.onSubmit.emit).toHaveBeenCalledOnce(); }); it('should show next submission if assessor or instructor, result is present and no complaint', () => { @@ -345,7 +345,7 @@ describe('AssessmentHeaderComponent', () => { const eventMock = new KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter' }); const spyOnControlAndEnter = jest.spyOn(component, 'submitOnControlAndEnter'); - const submitSpy = jest.spyOn(component.submit, 'emit'); + const submitSpy = jest.spyOn(component.onSubmit, 'emit'); document.dispatchEvent(eventMock); expect(spyOnControlAndEnter).toHaveBeenCalledOnce(); @@ -362,7 +362,7 @@ describe('AssessmentHeaderComponent', () => { const eventMock = new KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter' }); const spyOnControlAndEnter = jest.spyOn(component, 'submitOnControlAndEnter'); - const submitSpy = jest.spyOn(component.submit, 'emit'); + const submitSpy = jest.spyOn(component.onSubmit, 'emit'); document.dispatchEvent(eventMock); expect(spyOnControlAndEnter).toHaveBeenCalledOnce(); diff --git a/src/test/javascript/spec/component/complaints/complaints-form.component.spec.ts b/src/test/javascript/spec/component/complaints/complaints-form.component.spec.ts index c0e144ca8828..488c740be038 100644 --- a/src/test/javascript/spec/component/complaints/complaints-form.component.spec.ts +++ b/src/test/javascript/spec/component/complaints/complaints-form.component.spec.ts @@ -84,7 +84,7 @@ describe('ComplaintsFormComponent', () => { it('should submit after complaint creation', () => { const createMock = jest.spyOn(complaintService, 'create').mockReturnValue(of({} as EntityResponseType)); - const submitSpy = jest.spyOn(component.submit, 'emit'); + const submitSpy = jest.spyOn(component.onSubmit, 'emit'); component.createComplaint(); expect(createMock).toHaveBeenCalledOnce(); expect(submitSpy).toHaveBeenCalledOnce(); @@ -93,7 +93,7 @@ describe('ComplaintsFormComponent', () => { it('should throw unknown error after complaint creation', () => { const createMock = jest.spyOn(complaintService, 'create').mockReturnValue(throwError(() => ({ status: 400 }))); - const submitSpy = jest.spyOn(component.submit, 'emit'); + const submitSpy = jest.spyOn(component.onSubmit, 'emit'); const errorSpy = jest.spyOn(alertService, 'error'); component.createComplaint(); expect(createMock).toHaveBeenCalledOnce(); @@ -104,7 +104,7 @@ describe('ComplaintsFormComponent', () => { it('should throw known error after complaint creation', () => { const error = { error: { errorKey: 'tooManyComplaints' } } as HttpErrorResponse; const createMock = jest.spyOn(complaintService, 'create').mockReturnValue(throwError(() => error)); - const submitSpy = jest.spyOn(component.submit, 'emit'); + const submitSpy = jest.spyOn(component.onSubmit, 'emit'); const errorSpy = jest.spyOn(alertService, 'error'); const numberOfComplaints = 42; component.maxComplaintsPerCourse = numberOfComplaints; @@ -120,7 +120,7 @@ describe('ComplaintsFormComponent', () => { component.exercise = courseExercise; component.ngOnInit(); - const submitSpy = jest.spyOn(component.submit, 'emit'); + const submitSpy = jest.spyOn(component.onSubmit, 'emit'); const errorSpy = jest.spyOn(alertService, 'error'); // 26 characters component.complaintText = 'abcdefghijklmnopqrstuvwxyz'; From efe7c16f542e7954ea31ef7c9063e9931e72b046 Mon Sep 17 00:00:00 2001 From: Mohamed Bilel Besrour <58034472+BBesrour@users.noreply.github.com> Date: Sun, 27 Oct 2024 20:45:07 +0100 Subject: [PATCH 03/13] Development: Fix hazelcast issue on server shutdown (#9602) --- .../service/feature/FeatureToggleService.java | 91 +++++++++++++++---- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleService.java index 83438c369cfd..3e2a906bee8b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleService.java @@ -4,15 +4,19 @@ import java.util.List; import java.util.Map; +import java.util.Optional; -import jakarta.annotation.PostConstruct; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.core.HazelcastInstanceNotActiveException; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; @@ -20,6 +24,8 @@ @Service public class FeatureToggleService { + private static final Logger log = LoggerFactory.getLogger(FeatureToggleService.class); + private static final String TOPIC_FEATURE_TOGGLES = "/topic/management/feature-toggles"; @Value("${artemis.science.event-logging.enable:false}") @@ -36,10 +42,22 @@ public FeatureToggleService(WebsocketMessagingService websocketMessagingService, this.hazelcastInstance = hazelcastInstance; } + private Optional> getFeatures() { + try { + if (isHazelcastRunning()) { + return Optional.ofNullable(features); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to get features in {} as Hazelcast instance is not active anymore.", FeatureToggleService.class.getSimpleName()); + } + return Optional.empty(); + } + /** * Initialize relevant data from hazelcast */ - @PostConstruct + @EventListener(ApplicationReadyEvent.class) public void init() { // The map will automatically be distributed between all instances by Hazelcast. features = hazelcastInstance.getMap("features"); @@ -63,8 +81,10 @@ public void init() { * @param feature The feature that should be enabled */ public void enableFeature(Feature feature) { - features.put(feature, true); - sendUpdate(); + getFeatures().ifPresent(features -> { + features.put(feature, true); + sendUpdate(); + }); } /** @@ -73,23 +93,34 @@ public void enableFeature(Feature feature) { * @param feature The feature that should be disabled */ public void disableFeature(Feature feature) { - features.put(feature, false); - sendUpdate(); + getFeatures().ifPresent(features -> { + features.put(feature, false); + sendUpdate(); + }); } /** * Updates the given feature toggles and enables/disables the features based on the given map. Also notifies all clients * by sending a message via the websocket. * - * @param features A map of features (feature -> shouldBeActivated) + * @param updatedFeatures A map of features (feature -> shouldBeActivated) */ - public void updateFeatureToggles(final Map features) { - this.features.putAll(features); - sendUpdate(); + public void updateFeatureToggles(final Map updatedFeatures) { + getFeatures().ifPresent(features -> { + features.putAll(updatedFeatures); + sendUpdate(); + }); } private void sendUpdate() { - websocketMessagingService.sendMessage(TOPIC_FEATURE_TOGGLES, enabledFeatures()); + try { + if (isHazelcastRunning()) { + websocketMessagingService.sendMessage(TOPIC_FEATURE_TOGGLES, enabledFeatures()); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to send features update in {} as Hazelcast instance is not active anymore.", FeatureToggleService.class.getSimpleName()); + } } /** @@ -99,8 +130,16 @@ private void sendUpdate() { * @return if the feature is enabled */ public boolean isFeatureEnabled(Feature feature) { - Boolean isEnabled = features.get(feature); - return Boolean.TRUE.equals(isEnabled); + try { + if (isHazelcastRunning()) { + Boolean isEnabled = features.get(feature); + return Boolean.TRUE.equals(isEnabled); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to check if feature is enabled in FeatureToggleService as Hazelcast instance is not active any more."); + } + return false; } /** @@ -109,7 +148,15 @@ public boolean isFeatureEnabled(Feature feature) { * @return A list of enabled features */ public List enabledFeatures() { - return features.entrySet().stream().filter(feature -> Boolean.TRUE.equals(feature.getValue())).map(Map.Entry::getKey).toList(); + try { + if (isHazelcastRunning()) { + return features.entrySet().stream().filter(feature -> Boolean.TRUE.equals(feature.getValue())).map(Map.Entry::getKey).toList(); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to retrieve enabled features update in FeatureToggleService as Hazelcast instance is not active any more."); + } + return List.of(); } /** @@ -118,6 +165,18 @@ public List enabledFeatures() { * @return A list of disabled features */ public List disabledFeatures() { - return features.entrySet().stream().filter(feature -> Boolean.FALSE.equals(feature.getValue())).map(Map.Entry::getKey).toList(); + try { + if (isHazelcastRunning()) { + return features.entrySet().stream().filter(feature -> Boolean.FALSE.equals(feature.getValue())).map(Map.Entry::getKey).toList(); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to retrieve disabled features update in FeatureToggleService as Hazelcast instance is not active any more."); + } + return List.of(); + } + + private boolean isHazelcastRunning() { + return hazelcastInstance != null && hazelcastInstance.getLifecycleService().isRunning(); } } From 041d5c96360254b196a4d5126001f10809c19b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20St=C3=B6hr?= <38322605+JohannesStoehr@users.noreply.github.com> Date: Sun, 27 Oct 2024 20:56:53 +0100 Subject: [PATCH 04/13] Development: Ensure correct @Repository annotation usage (#9610) --- .../LongFeedbackTextRepository.java | 6 +++ .../CourseCompetencyRepository.java | 6 +++ .../repository/PrerequisiteRepository.java | 8 ++- .../repository/CustomPostRepository.java | 6 +++ .../core/repository/AuthorityRepository.java | 7 +++ .../repository/MigrationChangeRepository.java | 7 +++ .../PersistenceAuditEventRepository.java | 5 ++ ...IrisTextExerciseChatSessionRepository.java | 5 ++ .../repository/BuildPlanRepository.java | 5 ++ .../repository/hestia/CodeHintRepository.java | 6 +++ .../ExerciseHintActivationRepository.java | 6 +++ ...ammingExerciseSolutionEntryRepository.java | 6 +++ .../ProgrammingExerciseTaskRepository.java | 35 +++---------- .../atlas/AbstractAtlasIntegrationTest.java | 4 +- .../util/PrerequisiteUtilService.java | 4 +- .../PrerequisiteTestRepository.java | 19 +++++++ ...ProgrammingIntegrationIndependentTest.java | 4 +- ...ProgrammingExerciseTaskTestRepository.java | 52 +++++++++++++++++++ .../util/ProgrammingExerciseTestService.java | 4 +- .../util/ProgrammingExerciseUtilService.java | 4 +- ...tractModuleRepositoryArchitectureTest.java | 9 ++++ 21 files changed, 167 insertions(+), 41 deletions(-) create mode 100644 src/test/java/de/tum/cit/aet/artemis/atlas/test_repository/PrerequisiteTestRepository.java create mode 100644 src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTaskTestRepository.java diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/LongFeedbackTextRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/LongFeedbackTextRepository.java index 6ad61c4ef7ff..87df115afc0d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/LongFeedbackTextRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/LongFeedbackTextRepository.java @@ -1,14 +1,20 @@ package de.tum.cit.aet.artemis.assessment.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.List; import java.util.Optional; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.assessment.domain.LongFeedbackText; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +@Profile(PROFILE_CORE) +@Repository public interface LongFeedbackTextRepository extends ArtemisJpaRepository { @Query(""" diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java index d8b66519355c..0d672f1bcbc2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java @@ -1,14 +1,18 @@ package de.tum.cit.aet.artemis.atlas.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.List; import java.util.Optional; import java.util.Set; import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.atlas.domain.LearningObject; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; @@ -21,6 +25,8 @@ /** * Spring Data JPA repository for the {@link CourseCompetency} entity. */ +@Profile(PROFILE_CORE) +@Repository public interface CourseCompetencyRepository extends ArtemisJpaRepository { @Query(""" diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java index 9616c2a5f34b..5d7fc9c56e49 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java @@ -1,11 +1,15 @@ package de.tum.cit.aet.artemis.atlas.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.List; import java.util.Optional; import java.util.Set; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite; import de.tum.cit.aet.artemis.core.domain.Course; @@ -14,10 +18,10 @@ /** * Spring Data JPA repository for the {@link Prerequisite} entity. */ +@Profile(PROFILE_CORE) +@Repository public interface PrerequisiteRepository extends ArtemisJpaRepository { - List findAllByCourseIdOrderById(long courseId); - @Query(""" SELECT p FROM Prerequisite p diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepository.java index d40778fbaae6..db460a6b27c6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepository.java @@ -1,11 +1,17 @@ package de.tum.cit.aet.artemis.communication.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.communication.domain.Post; +@Profile(PROFILE_CORE) +@Repository public interface CustomPostRepository { Page findPostIdsWithSpecification(Specification specification, Pageable pageable); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/AuthorityRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/AuthorityRepository.java index 4e3f3f0466af..70a1078fbf7b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/AuthorityRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/AuthorityRepository.java @@ -1,13 +1,20 @@ package de.tum.cit.aet.artemis.core.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.List; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + import de.tum.cit.aet.artemis.core.domain.Authority; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; /** * Spring Data JPA repository for the Authority entity. */ +@Profile(PROFILE_CORE) +@Repository public interface AuthorityRepository extends ArtemisJpaRepository { /** diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/MigrationChangeRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/MigrationChangeRepository.java index 71b6b9c1a8c4..12ff470bed9f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/MigrationChangeRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/MigrationChangeRepository.java @@ -1,7 +1,14 @@ package de.tum.cit.aet.artemis.core.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + import de.tum.cit.aet.artemis.core.domain.MigrationChangelog; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +@Profile(PROFILE_CORE) +@Repository public interface MigrationChangeRepository extends ArtemisJpaRepository { } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/PersistenceAuditEventRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/PersistenceAuditEventRepository.java index 9c4c133fe6da..c2afe2117540 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/PersistenceAuditEventRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/PersistenceAuditEventRepository.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.core.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import static org.springframework.data.jpa.repository.EntityGraph.EntityGraphType.LOAD; import java.time.Instant; @@ -9,12 +10,14 @@ import jakarta.validation.constraints.NotNull; +import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.core.domain.PersistentAuditEvent; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -22,6 +25,8 @@ /** * Spring Data JPA repository for the PersistentAuditEvent entity. */ +@Profile(PROFILE_CORE) +@Repository public interface PersistenceAuditEventRepository extends ArtemisJpaRepository { @EntityGraph(type = LOAD, attributePaths = { "data" }) diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTextExerciseChatSessionRepository.java b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTextExerciseChatSessionRepository.java index a8f76c5ff679..be8d6c3b4331 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTextExerciseChatSessionRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTextExerciseChatSessionRepository.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.iris.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; import static org.springframework.data.jpa.repository.EntityGraph.EntityGraphType.LOAD; import java.util.Collections; @@ -7,10 +8,12 @@ import jakarta.validation.constraints.NotNull; +import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.core.domain.DomainObject; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; @@ -21,6 +24,8 @@ * Repository interface for managing {@link IrisTextExerciseChatSession} entities. * Provides custom queries for finding text exercise chat sessions based on different criteria. */ +@Profile(PROFILE_IRIS) +@Repository public interface IrisTextExerciseChatSessionRepository extends ArtemisJpaRepository { /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildPlanRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildPlanRepository.java index 1a1cd6167bea..9e9c996a8e37 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildPlanRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildPlanRepository.java @@ -1,17 +1,22 @@ package de.tum.cit.aet.artemis.programming.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import static org.springframework.data.jpa.repository.EntityGraph.EntityGraphType.LOAD; import java.util.Optional; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.build.BuildPlan; +@Profile(PROFILE_CORE) +@Repository public interface BuildPlanRepository extends ArtemisJpaRepository { @Query(""" diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/CodeHintRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/CodeHintRepository.java index 7c78408aedb6..9c4a03766832 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/CodeHintRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/CodeHintRepository.java @@ -1,12 +1,16 @@ package de.tum.cit.aet.artemis.programming.repository.hestia; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.Optional; import java.util.Set; import jakarta.validation.constraints.NotNull; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -15,6 +19,8 @@ /** * Spring Data repository for the CodeHint entity. */ +@Profile(PROFILE_CORE) +@Repository public interface CodeHintRepository extends ArtemisJpaRepository { Set findByExerciseId(Long exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ExerciseHintActivationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ExerciseHintActivationRepository.java index 5a24463cdc7f..c827a9b3052b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ExerciseHintActivationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ExerciseHintActivationRepository.java @@ -1,14 +1,20 @@ package de.tum.cit.aet.artemis.programming.repository.hestia; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.Optional; import java.util.Set; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; import de.tum.cit.aet.artemis.programming.domain.hestia.ExerciseHintActivation; +@Profile(PROFILE_CORE) +@Repository public interface ExerciseHintActivationRepository extends ArtemisJpaRepository { @Query(""" diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseSolutionEntryRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseSolutionEntryRepository.java index 839a7d67dc49..14a03ed49c0a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseSolutionEntryRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseSolutionEntryRepository.java @@ -1,12 +1,16 @@ package de.tum.cit.aet.artemis.programming.repository.hestia; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.Optional; import java.util.Set; import jakarta.validation.constraints.NotNull; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -15,6 +19,8 @@ /** * Spring Data repository for the ProgrammingExerciseSolutionEntry entity. */ +@Profile(PROFILE_CORE) +@Repository public interface ProgrammingExerciseSolutionEntryRepository extends ArtemisJpaRepository { /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java index 432727c61a3e..778c0c811374 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java @@ -1,13 +1,17 @@ package de.tum.cit.aet.artemis.programming.repository.hestia; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.List; import java.util.Optional; import java.util.Set; import jakarta.validation.constraints.NotNull; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -16,37 +20,10 @@ /** * Spring Data repository for the ProgrammingExerciseTask entity. */ +@Profile(PROFILE_CORE) +@Repository public interface ProgrammingExerciseTaskRepository extends ArtemisJpaRepository { - Set findByExerciseId(Long exerciseId); - - /** - * Gets a task with its programming exercise, test cases and solution entries of the test cases - * - * @param entryId The id of the task - * @return The task with the given ID if found - * @throws EntityNotFoundException If no task with the given ID was found - */ - @NotNull - default ProgrammingExerciseTask findByIdWithTestCaseAndSolutionEntriesElseThrow(long entryId) throws EntityNotFoundException { - return getValueElseThrow(findByIdWithTestCaseAndSolutionEntries(entryId), entryId); - } - - /** - * Gets a task with its programming exercise, test cases and solution entries of the test cases - * - * @param entryId The id of the task - * @return The task with the given ID - */ - @Query(""" - SELECT t - FROM ProgrammingExerciseTask t - LEFT JOIN FETCH t.testCases tc - LEFT JOIN FETCH tc.solutionEntries - WHERE t.id = :entryId - """) - Optional findByIdWithTestCaseAndSolutionEntries(@Param("entryId") long entryId); - /** * Gets all tasks with its test cases and solution entries of the test case for a programming exercise * diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/AbstractAtlasIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/AbstractAtlasIntegrationTest.java index 744c27d2f937..f2513d50f2f5 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/AbstractAtlasIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/AbstractAtlasIntegrationTest.java @@ -14,13 +14,13 @@ import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; import de.tum.cit.aet.artemis.atlas.repository.KnowledgeAreaRepository; -import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository; import de.tum.cit.aet.artemis.atlas.repository.ScienceSettingRepository; import de.tum.cit.aet.artemis.atlas.repository.SourceRepository; import de.tum.cit.aet.artemis.atlas.repository.StandardizedCompetencyRepository; import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.atlas.test_repository.CompetencyProgressTestRepository; import de.tum.cit.aet.artemis.atlas.test_repository.LearningPathTestRepository; +import de.tum.cit.aet.artemis.atlas.test_repository.PrerequisiteTestRepository; import de.tum.cit.aet.artemis.atlas.test_repository.ScienceEventTestRepository; import de.tum.cit.aet.artemis.core.service.feature.FeatureToggleService; import de.tum.cit.aet.artemis.core.util.PageableSearchUtilService; @@ -74,7 +74,7 @@ public abstract class AbstractAtlasIntegrationTest extends AbstractSpringIntegra protected ScienceEventTestRepository scienceEventRepository; @Autowired - protected PrerequisiteRepository prerequisiteRepository; + protected PrerequisiteTestRepository prerequisiteRepository; @Autowired protected CompetencyJolRepository competencyJolRepository; diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/competency/util/PrerequisiteUtilService.java b/src/test/java/de/tum/cit/aet/artemis/atlas/competency/util/PrerequisiteUtilService.java index edf6e9378286..ac23fe4ef531 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/competency/util/PrerequisiteUtilService.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/competency/util/PrerequisiteUtilService.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite; -import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository; +import de.tum.cit.aet.artemis.atlas.test_repository.PrerequisiteTestRepository; import de.tum.cit.aet.artemis.core.domain.Course; /** @@ -17,7 +17,7 @@ public class PrerequisiteUtilService { @Autowired - private PrerequisiteRepository prerequisiteRepository; + private PrerequisiteTestRepository prerequisiteRepository; /** * Creates and saves a Prerequisite competency for the given Course. diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/test_repository/PrerequisiteTestRepository.java b/src/test/java/de/tum/cit/aet/artemis/atlas/test_repository/PrerequisiteTestRepository.java new file mode 100644 index 000000000000..a3a8e54cbe3b --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/test_repository/PrerequisiteTestRepository.java @@ -0,0 +1,19 @@ +package de.tum.cit.aet.artemis.atlas.test_repository; + +import java.util.List; + +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite; +import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository; + +/** + * Spring Data JPA repository for the {@link Prerequisite} entity. + */ +@Repository +@Primary +public interface PrerequisiteTestRepository extends PrerequisiteRepository { + + List findAllByCourseIdOrderById(long courseId); +} diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/AbstractProgrammingIntegrationIndependentTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/AbstractProgrammingIntegrationIndependentTest.java index 46749fbcd3ec..21791e5d24f7 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/AbstractProgrammingIntegrationIndependentTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/AbstractProgrammingIntegrationIndependentTest.java @@ -25,7 +25,6 @@ import de.tum.cit.aet.artemis.programming.repository.hestia.ExerciseHintActivationRepository; import de.tum.cit.aet.artemis.programming.repository.hestia.ExerciseHintRepository; import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseSolutionEntryRepository; -import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseTaskRepository; import de.tum.cit.aet.artemis.programming.repository.hestia.TestwiseCoverageReportEntryRepository; import de.tum.cit.aet.artemis.programming.repository.settings.IdeRepository; import de.tum.cit.aet.artemis.programming.repository.settings.UserIdeMappingRepository; @@ -38,6 +37,7 @@ import de.tum.cit.aet.artemis.programming.service.hestia.ExerciseHintService; import de.tum.cit.aet.artemis.programming.service.hestia.ProgrammingExerciseTaskService; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseStudentParticipationTestRepository; +import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTaskTestRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestCaseTestRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingSubmissionTestRepository; @@ -79,7 +79,7 @@ public abstract class AbstractProgrammingIntegrationIndependentTest extends Abst protected ProgrammingExerciseStudentParticipationTestRepository programmingExerciseStudentParticipationRepository; @Autowired - protected ProgrammingExerciseTaskRepository taskRepository; + protected ProgrammingExerciseTaskTestRepository taskRepository; @Autowired protected ProgrammingExerciseTestCaseTestRepository testCaseRepository; diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTaskTestRepository.java b/src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTaskTestRepository.java new file mode 100644 index 000000000000..7d79da2bdcb6 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTaskTestRepository.java @@ -0,0 +1,52 @@ +package de.tum.cit.aet.artemis.programming.test_repository; + +import java.util.Optional; +import java.util.Set; + +import jakarta.validation.constraints.NotNull; + +import org.springframework.context.annotation.Primary; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; +import de.tum.cit.aet.artemis.programming.domain.hestia.ProgrammingExerciseTask; +import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseTaskRepository; + +/** + * Spring Data repository for the ProgrammingExerciseTask entity. + */ +@Repository +@Primary +public interface ProgrammingExerciseTaskTestRepository extends ProgrammingExerciseTaskRepository { + + Set findByExerciseId(Long exerciseId); + + /** + * Gets a task with its programming exercise, test cases and solution entries of the test cases + * + * @param entryId The id of the task + * @return The task with the given ID if found + * @throws EntityNotFoundException If no task with the given ID was found + */ + @NotNull + default ProgrammingExerciseTask findByIdWithTestCaseAndSolutionEntriesElseThrow(long entryId) throws EntityNotFoundException { + return getValueElseThrow(findByIdWithTestCaseAndSolutionEntries(entryId), entryId); + } + + /** + * Gets a task with its programming exercise, test cases and solution entries of the test cases + * + * @param entryId The id of the task + * @return The task with the given ID + */ + @Query(""" + SELECT t + FROM ProgrammingExerciseTask t + LEFT JOIN FETCH t.testCases tc + LEFT JOIN FETCH tc.solutionEntries + WHERE t.id = :entryId + """) + Optional findByIdWithTestCaseAndSolutionEntries(@Param("entryId") long entryId); +} diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseTestService.java b/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseTestService.java index 95ba2804b3b6..b8963f3decae 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseTestService.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseTestService.java @@ -136,7 +136,6 @@ import de.tum.cit.aet.artemis.programming.repository.BuildPlanRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildConfigRepository; import de.tum.cit.aet.artemis.programming.repository.StaticCodeAnalysisCategoryRepository; -import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseTaskRepository; import de.tum.cit.aet.artemis.programming.service.AutomaticProgrammingExerciseCleanupService; import de.tum.cit.aet.artemis.programming.service.GitService; import de.tum.cit.aet.artemis.programming.service.JavaTemplateUpgradeService; @@ -148,6 +147,7 @@ import de.tum.cit.aet.artemis.programming.service.vcs.VersionControlRepositoryPermission; import de.tum.cit.aet.artemis.programming.service.vcs.VersionControlService; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseStudentParticipationTestRepository; +import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTaskTestRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestCaseTestRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingSubmissionTestRepository; @@ -228,7 +228,7 @@ public class ProgrammingExerciseTestService { private JavaTemplateUpgradeService javaTemplateUpgradeService; @Autowired - private ProgrammingExerciseTaskRepository programmingExerciseTaskRepository; + private ProgrammingExerciseTaskTestRepository programmingExerciseTaskRepository; @Autowired private ProgrammingExerciseTestCaseTestRepository programmingExerciseTestCaseRepository; diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseUtilService.java b/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseUtilService.java index e1571792335a..7aa1d99e1c8f 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseUtilService.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseUtilService.java @@ -74,8 +74,8 @@ import de.tum.cit.aet.artemis.programming.repository.hestia.CodeHintRepository; import de.tum.cit.aet.artemis.programming.repository.hestia.ExerciseHintRepository; import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseSolutionEntryRepository; -import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseTaskRepository; import de.tum.cit.aet.artemis.programming.service.GitService; +import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTaskTestRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestCaseTestRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingSubmissionTestRepository; @@ -145,7 +145,7 @@ public class ProgrammingExerciseUtilService { private ExerciseHintRepository exerciseHintRepository; @Autowired - private ProgrammingExerciseTaskRepository programmingExerciseTaskRepository; + private ProgrammingExerciseTaskTestRepository programmingExerciseTaskRepository; @Autowired private ProgrammingExerciseSolutionEntryRepository solutionEntryRepository; diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleRepositoryArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleRepositoryArchitectureTest.java index 6e3887654163..fdc272e7877d 100644 --- a/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleRepositoryArchitectureTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleRepositoryArchitectureTest.java @@ -20,6 +20,7 @@ import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.NoRepositoryBean; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.stereotype.Service; @@ -48,6 +49,14 @@ void shouldBeNamedRepository() { rule.allowEmptyShould(true).check(allClasses); } + @Test + void shouldBeAnnotatedRepository() { + ArchRule rule = classesOfThisModuleThat().haveSimpleNameEndingWith("Repository").and().areInterfaces().should().beAnnotatedWith(Repository.class).orShould() + .beAnnotatedWith(NoRepositoryBean.class).because("repositories should be annotated with @Repository or @NoRepositoryBean."); + // allow empty should since some modules do not have repositories + rule.allowEmptyShould(true).check(allClasses); + } + @Test void shouldBeInRepositoryPackage() { ArchRule rule = classesOfThisModuleThat().areAnnotatedWith(Repository.class).should().resideInAPackage("..repository..") From 981fe87f1089225d453cb1fdaf6d3617e623339d Mon Sep 17 00:00:00 2001 From: Asli Aykan <56061820+asliayk@users.noreply.github.com> Date: Sun, 27 Oct 2024 20:58:03 +0100 Subject: [PATCH 05/13] Communication: Fix content overflow in expanded thread view (#9474) --- .../course-conversations.component.html | 5 +---- .../course-conversations.component.scss | 17 ++--------------- .../conversation-thread-sidebar.component.scss | 17 +++++++++-------- .../conversation-thread-sidebar.component.ts | 4 ++-- .../answer-post-reactions-bar.component.html | 2 +- .../post-reactions-bar.component.html | 2 +- ...onversation-thread-sidebar.component.spec.ts | 12 ++++++++++++ 7 files changed, 28 insertions(+), 31 deletions(-) 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 113ab1ca1cd8..1e7b44fa6fb7 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 @@ -62,10 +62,7 @@ } -
+
@if (!!postInThread) { +
@for (reactionMetaData of reactionMetaDataMap | keyvalue; track reactionMetaData) {
+@if (settingsType === COURSE && (subSettings?.type === CHAT || subSettings?.type === TEXT_EXERCISE_CHAT)) { +

+
+
+ @for (category of categories; track category) { +
+ + +
+ } +
+
+}

: @if (parentSubSettings) { diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts b/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts index ba5bd6573691..0d4f1898653b 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts @@ -6,6 +6,11 @@ import { ButtonType } from 'app/shared/components/button.component'; import { faTrash } from '@fortawesome/free-solid-svg-icons'; import { IrisSettingsType } from 'app/entities/iris/settings/iris-settings.model'; import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { onError } from 'app/shared/util/global.utils'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { AlertService } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-iris-common-sub-settings-update', @@ -21,6 +26,9 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { @Input() settingsType: IrisSettingsType; + @Input() + courseId?: number; + @Output() onChanges = new EventEmitter(); @@ -34,23 +42,34 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { enabled: boolean; + categories: string[] = []; + // Settings types EXERCISE = IrisSettingsType.EXERCISE; COURSE = IrisSettingsType.COURSE; + TEXT_EXERCISE_CHAT = IrisSubSettingsType.TEXT_EXERCISE_CHAT; + CHAT = IrisSubSettingsType.CHAT; // Button types WARNING = ButtonType.WARNING; // Icons faTrash = faTrash; + protected readonly IrisSubSettings = IrisSubSettings; + protected readonly IrisSubSettingsType = IrisSubSettingsType; + constructor( accountService: AccountService, private irisSettingsService: IrisSettingsService, + private courseManagementService: CourseManagementService, + private exerciseService: ExerciseService, + private alertService: AlertService, ) { this.isAdmin = accountService.isAdmin(); } ngOnInit() { this.enabled = this.subSettings?.enabled ?? false; + this.loadCategories(); this.loadVariants(); this.inheritAllowedVariants = !!(!this.subSettings?.allowedVariants && this.parentSubSettings); } @@ -64,6 +83,23 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { } } + loadCategories() { + if (this.settingsType === this.COURSE) { + this.courseManagementService.findAllCategoriesOfCourse(this.courseId!).subscribe({ + next: (response: HttpResponse) => { + this.categories = this.exerciseService + .convertExerciseCategoriesAsStringFromServer(response.body!) + .map((category) => category.category) + .filter((category) => category !== undefined) + .map((category) => category!); + // Remove duplicate categories + this.categories = Array.from(new Set(this.categories)); + }, + error: (error: HttpErrorResponse) => onError(this.alertService, error), + }); + } + } + loadVariants(): void { if (!this.subSettings?.type) { return; @@ -123,15 +159,28 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { } } + onCategorySelectionChange(category: string) { + if (!this.subSettings) { + return; + } + if (!this.subSettings.enabledForCategories) { + this.subSettings.enabledForCategories = []; + } + if (this.subSettings.enabledForCategories?.includes(category)) { + this.subSettings.enabledForCategories = this.subSettings.enabledForCategories!.filter((c) => c !== category); + } else { + this.subSettings.enabledForCategories = [...(this.subSettings.enabledForCategories ?? []), category]; + } + } + get inheritDisabled() { if (this.parentSubSettings) { return !this.parentSubSettings.enabled; } return false; } + get isSettingsSwitchDisabled() { return this.inheritDisabled || (!this.isAdmin && this.settingsType !== this.EXERCISE); } - protected readonly IrisSubSettings = IrisSubSettings; - protected readonly IrisSubSettingsType = IrisSubSettingsType; } diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html index 8865d90aee42..a2b7109c01c2 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html @@ -17,6 +17,7 @@

[subSettings]="irisSettings?.irisChatSettings" [parentSubSettings]="parentIrisSettings?.irisChatSettings" [settingsType]="settingsType" + [courseId]="courseId" (onChanges)="isDirty = true" />
@@ -27,6 +28,7 @@

@@ -38,6 +40,7 @@


@@ -62,6 +65,7 @@

diff --git a/src/main/webapp/i18n/de/iris.json b/src/main/webapp/i18n/de/iris.json index 0a687f629ebe..70627d2052c0 100644 --- a/src/main/webapp/i18n/de/iris.json +++ b/src/main/webapp/i18n/de/iris.json @@ -33,6 +33,7 @@ "hestiaSettings": "Hestia Einstellungen", "competencyGenerationSettings": "Kompetenzgenerierung Einstellungen", "enabled-disabled": "Aktiviert/Deaktiviert", + "enabledForCategories": "Automatisch aktivieren für Kategorien", "variants": { "title": "Varianten", "allowedVariants": { diff --git a/src/main/webapp/i18n/en/iris.json b/src/main/webapp/i18n/en/iris.json index 8bf0df8dafdc..d3ba981c4419 100644 --- a/src/main/webapp/i18n/en/iris.json +++ b/src/main/webapp/i18n/en/iris.json @@ -33,6 +33,7 @@ "hestiaSettings": "Hestia Settings", "competencyGenerationSettings": "Competency Generation Settings", "enabled-disabled": "Enabled/Disabled", + "enabledForCategories": "Automatically enable for categories", "variants": { "title": "Variants", "allowedVariants": { diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java index 4ead87fa669a..8e877d1e50e2 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java @@ -2,15 +2,26 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.time.ZonedDateTime; import java.util.HashSet; +import java.util.List; +import java.util.SortedSet; import java.util.TreeSet; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.iris.AbstractIrisIntegrationTest; import de.tum.cit.aet.artemis.iris.domain.settings.IrisChatSubSettings; @@ -23,6 +34,9 @@ import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; import de.tum.cit.aet.artemis.iris.repository.IrisSubSettingsRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.service.aeolus.AeolusTemplateService; +import de.tum.cit.aet.artemis.text.domain.TextExercise; +import de.tum.cit.aet.artemis.text.util.TextExerciseUtilService; class IrisSettingsIntegrationTest extends AbstractIrisIntegrationTest { @@ -34,15 +48,58 @@ class IrisSettingsIntegrationTest extends AbstractIrisIntegrationTest { @Autowired private IrisSettingsRepository irisSettingsRepository; + @Autowired + private AeolusTemplateService aeolusTemplateService; + + @Autowired + private TextExerciseUtilService textExerciseUtilService; + private Course course; private ProgrammingExercise programmingExercise; + private TextExercise textExercise; + + private static Stream getCourseSettingsCategoriesSource() { + return Stream.of(Arguments.of(List.of("COURSE"), List.of(List.of("category1")), false), + Arguments.of(List.of("COURSE", "EXERCISE"), List.of(List.of("category1"), List.of("category1")), true), + Arguments.of(List.of("EXERCISE", "COURSE"), List.of(List.of("category1"), List.of("category1")), true), + Arguments.of(List.of("EXERCISE"), List.of(List.of("category1")), false), + Arguments.of(List.of("EXERCISE", "COURSE", "EXERCISE"), List.of(List.of("category1"), List.of("category1"), List.of()), false), + Arguments.of(List.of("COURSE", "EXERCISE", "COURSE"), List.of(List.of("category1"), List.of("category1"), List.of()), false), + Arguments.of(List.of("EXERCISE", "COURSE", "EXERCISE"), List.of(List.of("category1", "category2"), List.of("category1"), List.of("category2")), false), + Arguments.of(List.of("EXERCISE", "COURSE", "EXERCISE"), List.of(List.of("category1", "category2"), List.of("category1"), List.of("category1")), true), + Arguments.of(List.of("EXERCISE", "COURSE", "EXERCISE"), List.of(List.of("category1"), List.of("category2"), List.of("category2")), true), + Arguments.of(List.of("COURSE", "EXERCISE", "COURSE"), List.of(List.of("category1", "category2"), List.of("category1"), List.of("category2")), false), + Arguments.of(List.of("COURSE", "EXERCISE", "COURSE"), List.of(List.of("category1", "category2"), List.of("category1"), List.of("category1")), true)); + } + @BeforeEach - void initTestCase() { + void initTestCase() throws JsonProcessingException { userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 1); - course = programmingExerciseUtilService.addCourseWithOneProgrammingExerciseAndTestCases(); + course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); programmingExercise = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); + var projectKey1 = programmingExercise.getProjectKey(); + programmingExercise.setTestRepositoryUri(localVCBaseUrl + "/git/" + projectKey1 + "/" + projectKey1.toLowerCase() + "-tests.git"); + programmingExercise.getBuildConfig().setBuildPlanConfiguration(new ObjectMapper().writeValueAsString(aeolusTemplateService.getDefaultWindfileFor(programmingExercise))); + programmingExerciseBuildConfigRepository.save(programmingExercise.getBuildConfig()); + programmingExerciseRepository.save(programmingExercise); + programmingExercise = programmingExerciseRepository.findWithAllParticipationsAndBuildConfigById(programmingExercise.getId()).orElseThrow(); + + var templateRepositorySlug = localVCLocalCITestService.getRepositorySlug(projectKey1, "exercise"); + var templateParticipation = programmingExercise.getTemplateParticipation(); + templateParticipation.setRepositoryUri(localVCBaseUrl + "/git/" + projectKey1 + "/" + templateRepositorySlug + ".git"); + templateProgrammingExerciseParticipationRepository.save(templateParticipation); + var solutionRepositorySlug = localVCLocalCITestService.getRepositorySlug(projectKey1, "solution"); + var solutionParticipation = programmingExercise.getSolutionParticipation(); + solutionParticipation.setRepositoryUri(localVCBaseUrl + "/git/" + projectKey1 + "/" + solutionRepositorySlug + ".git"); + solutionProgrammingExerciseParticipationRepository.save(solutionParticipation); + + // Text Exercise + ZonedDateTime pastReleaseDate = ZonedDateTime.now().minusDays(5); + ZonedDateTime pastDueDate = ZonedDateTime.now().minusDays(3); + ZonedDateTime pastAssessmentDueDate = ZonedDateTime.now().minusDays(2); + textExercise = textExerciseUtilService.createIndividualTextExercise(course, pastReleaseDate, pastDueDate, pastAssessmentDueDate); } @Test @@ -169,6 +226,51 @@ void updateCourseSettings3() throws Exception { "irisLectureIngestionSettings.id", "irisCompetencyGenerationSettings.id", "irisCompetencyGenerationSettings.template.id").isEqualTo(courseSettings); } + /** + * This test check if exercises get correctly enabled and disabled based on the categories in the course settings. + * + * @param operations List of operations to perform on the settings. Possible values are "COURSE" and "EXERCISE". + * @param categories List of categories to set for the course and exercise settings. + * @param exerciseEnabled Expected value of the exercise enabled flag. + * @throws Exception If something request fails. + */ + @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") + @MethodSource("getCourseSettingsCategoriesSource") + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void updateCourseSettingsCategories(List operations, List> categories, boolean exerciseEnabled) throws Exception { + activateIrisGlobally(); + activateIrisFor(course); + course = courseRepository.findByIdElseThrow(course.getId()); + + for (int i = 0; i < operations.size(); i++) { + String operation = operations.get(i); + SortedSet category = new TreeSet<>(categories.get(i)); + if (operation.equals("COURSE")) { + var loadedSettings = request.get("/api/courses/" + course.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); + loadedSettings.getIrisChatSettings().setEnabledForCategories(category); + loadedSettings.getIrisTextExerciseChatSettings().setEnabledForCategories(category); + request.putWithResponseBody("/api/courses/" + course.getId() + "/raw-iris-settings", loadedSettings, IrisSettings.class, HttpStatus.OK); + } + else if (operation.equals("EXERCISE")) { + programmingExercise = programmingExerciseRepository.findWithAllParticipationsAndBuildConfigById(programmingExercise.getId()).orElseThrow(); + programmingExercise.setCategories(category.stream().map(cat -> "{\"color\":\"#6ae8ac\",\"category\":\"" + cat + "\"}").collect(Collectors.toSet())); + request.putWithResponseBody("/api/programming-exercises", programmingExercise, ProgrammingExercise.class, HttpStatus.OK); + + textExercise = (TextExercise) exerciseRepository.findByIdElseThrow(textExercise.getId()); + textExercise.setCategories(category.stream().map(cat -> "{\"color\":\"#6ae8ac\",\"category\":\"" + cat + "\"}").collect(Collectors.toSet())); + request.putWithResponseBody("/api/text-exercises", textExercise, TextExercise.class, HttpStatus.OK); + } + } + + // Load programming exercise Iris settings + var loadedSettings1 = request.get("/api/exercises/" + programmingExercise.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); + assertThat(loadedSettings1.getIrisChatSettings().isEnabled()).isEqualTo(exerciseEnabled); + + // Load text exercise Iris settings + var loadedSettings2 = request.get("/api/exercises/" + textExercise.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); + assertThat(loadedSettings2.getIrisTextExerciseChatSettings().isEnabled()).isEqualTo(exerciseEnabled); + } + @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void getMissingSettingsForProgrammingExercise() throws Exception { diff --git a/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts b/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts index 2b36487e4576..d43efbcc98e4 100644 --- a/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts +++ b/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts @@ -11,6 +11,9 @@ import { IrisSettingsType } from 'app/entities/iris/settings/iris-settings.model import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; import { of } from 'rxjs'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { HttpResponse } from '@angular/common/http'; function baseSettings() { const irisSubSettings = new IrisChatSubSettings(); @@ -23,10 +26,20 @@ function baseSettings() { return irisSubSettings; } +function mockCategories() { + return [ + // Convert ExerciseCategory to json string + JSON.stringify(new ExerciseCategory('category1', '0xff0000')), + JSON.stringify(new ExerciseCategory('category2', '0x00ff00')), + JSON.stringify(new ExerciseCategory('category3', '0x0000ff')), + ]; +} + describe('IrisCommonSubSettingsUpdateComponent Component', () => { let comp: IrisCommonSubSettingsUpdateComponent; let fixture: ComponentFixture; let getVariantsSpy: jest.SpyInstance; + let getCategoriesSpy: jest.SpyInstance; beforeEach(() => { TestBed.configureTestingModule({ @@ -36,7 +49,9 @@ describe('IrisCommonSubSettingsUpdateComponent Component', () => { .compileComponents() .then(() => { const irisSettingsService = TestBed.inject(IrisSettingsService); + const courseManagementService = TestBed.inject(CourseManagementService); getVariantsSpy = jest.spyOn(irisSettingsService, 'getVariantsForFeature').mockReturnValue(of(mockVariants())); + getCategoriesSpy = jest.spyOn(courseManagementService, 'findAllCategoriesOfCourse').mockReturnValue(of(new HttpResponse({ body: mockCategories() }))); }); fixture = TestBed.createComponent(IrisCommonSubSettingsUpdateComponent); comp = fixture.componentInstance; @@ -181,4 +196,27 @@ describe('IrisCommonSubSettingsUpdateComponent Component', () => { expect(comp.enabled).toBeFalse(); expect(comp.allowedVariants).toEqual(newModels); }); + + it('enable categories', () => { + comp.subSettings = baseSettings(); + comp.parentSubSettings = baseSettings(); + comp.parentSubSettings.enabled = false; + comp.isAdmin = true; + comp.settingsType = IrisSettingsType.COURSE; + comp.availableVariants = mockVariants(); + fixture.detectChanges(); + + expect(getCategoriesSpy).toHaveBeenCalledOnce(); + + comp.onCategorySelectionChange('category1'); + expect(comp.subSettings!.enabledForCategories).toEqual(['category1']); + comp.onCategorySelectionChange('category2'); + expect(comp.subSettings!.enabledForCategories).toEqual(['category1', 'category2']); + comp.onCategorySelectionChange('category1'); + expect(comp.subSettings!.enabledForCategories).toEqual(['category2']); + + comp.subSettings = undefined; + comp.onCategorySelectionChange('category1'); + expect(comp.subSettings).toBeUndefined(); + }); }); From 9958d1704356ff57547053b988b846eabec7ba26 Mon Sep 17 00:00:00 2001 From: Murad Talibov <56686446+muradium@users.noreply.github.com> Date: Sun, 27 Oct 2024 22:53:38 +0100 Subject: [PATCH 11/13] Development: Fix e2e tests for modeling exercises (#9613) --- .../support/pageobjects/exam/ExamParticipationPage.ts | 2 +- .../support/pageobjects/exercises/quiz/DragAndDropQuiz.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/playwright/support/pageobjects/exam/ExamParticipationPage.ts b/src/test/playwright/support/pageobjects/exam/ExamParticipationPage.ts index 5af59d960050..b447c9c1d289 100644 --- a/src/test/playwright/support/pageobjects/exam/ExamParticipationPage.ts +++ b/src/test/playwright/support/pageobjects/exam/ExamParticipationPage.ts @@ -85,9 +85,9 @@ export class ExamParticipationPage extends ExamParticipationActions { } private async makeModelingExerciseSubmission(exerciseID: number) { - await this.modelingExerciseEditor.addComponentToModel(exerciseID, 1); await this.modelingExerciseEditor.addComponentToModel(exerciseID, 2); await this.modelingExerciseEditor.addComponentToModel(exerciseID, 3); + await this.modelingExerciseEditor.addComponentToModel(exerciseID, 4); } private async makeQuizExerciseSubmission(exerciseID: number, quizExerciseID: number) { diff --git a/src/test/playwright/support/pageobjects/exercises/quiz/DragAndDropQuiz.ts b/src/test/playwright/support/pageobjects/exercises/quiz/DragAndDropQuiz.ts index a046c8f21a34..00c7240d06f7 100644 --- a/src/test/playwright/support/pageobjects/exercises/quiz/DragAndDropQuiz.ts +++ b/src/test/playwright/support/pageobjects/exercises/quiz/DragAndDropQuiz.ts @@ -28,7 +28,7 @@ export class DragAndDropQuiz { } async dragUsingCoordinates(x: number, y: number) { - const classElement = this.page.locator('#modeling-editor-sidebar').locator('div').nth(2); + const classElement = this.page.locator('#modeling-editor-sidebar').locator('div').nth(3); const modelingEditorCanvas = this.page.locator(MODELING_EDITOR_CANVAS); await classElement.dragTo(modelingEditorCanvas, { targetPosition: { x: x, y: y } }); } From 3b1b15e5e7ba82e7e500f448fb688984981bc519 Mon Sep 17 00:00:00 2001 From: Benjamin Schmitz <66966223+bensofficial@users.noreply.github.com> Date: Sun, 27 Oct 2024 23:11:59 +0100 Subject: [PATCH 12/13] Development: Improve exercise configuration for GitLab CI (#9608) --- .../ProgrammingExerciseRepository.java | 15 ++ .../service/gitlabci/GitLabCIService.java | 185 ++++++++++++------ .../GitLabCIUserManagementService.java | 2 +- .../gitlabci/empty/regularRuns/.gitlab-ci.yml | 7 +- .../java/maven/regularRuns/.gitlab-ci.yml | 7 +- .../gitlabci/rust/regularRuns/.gitlab-ci.yml | 7 +- .../connector/GitlabRequestMockProvider.java | 11 ++ .../service/GitlabCIServiceTest.java | 16 ++ .../ProgrammingExerciseTestRepository.java | 11 -- ...ringIntegrationGitlabCIGitlabSamlTest.java | 1 + 10 files changed, 188 insertions(+), 74 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java index c987ea67cbb3..578ebde6c8fd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java @@ -124,6 +124,9 @@ Optional findWithTemplateAndSolutionParticipationTeamAssign @EntityGraph(type = LOAD, attributePaths = "submissionPolicy") List findWithSubmissionPolicyByProjectKey(String projectKey); + @EntityGraph(type = LOAD, attributePaths = { "buildConfig" }) + Optional findWithBuildConfigById(long exerciseId); + /** * Finds one programming exercise including its submission policy by the exercise's project key. * @@ -746,6 +749,18 @@ default ProgrammingExercise findForCreationByIdElseThrow(long programmingExercis return getValueElseThrow(findForCreationById(programmingExerciseId), programmingExerciseId); } + /** + * Find a programming exercise by its id, with eagerly loaded build config. + * + * @param programmingExerciseId of the programming exercise. + * @return The programming exercise related to the given id + * @throws EntityNotFoundException the programming exercise could not be found. + */ + @NotNull + default ProgrammingExercise findByIdWithBuildConfigElseThrow(long programmingExerciseId) throws EntityNotFoundException { + return getValueElseThrow(findWithBuildConfigById(programmingExerciseId), programmingExerciseId); + } + /** * Saves the given programming exercise to the database. *

diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIService.java index 9292031adba5..e0ec98ed52cf 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIService.java @@ -3,18 +3,25 @@ import static de.tum.cit.aet.artemis.core.config.Constants.NEW_RESULT_RESOURCE_API_PATH; import java.net.URL; +import java.time.ZonedDateTime; import java.util.Comparator; +import java.util.Date; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Supplier; +import org.gitlab4j.api.Constants; import org.gitlab4j.api.GitLabApi; import org.gitlab4j.api.GitLabApiException; +import org.gitlab4j.api.GroupApi; import org.gitlab4j.api.ProjectApi; +import org.gitlab4j.api.models.AccessLevel; import org.gitlab4j.api.models.Pipeline; import org.gitlab4j.api.models.PipelineFilter; import org.gitlab4j.api.models.PipelineStatus; import org.gitlab4j.api.models.Project; +import org.gitlab4j.api.models.ProjectAccessToken; import org.gitlab4j.api.models.Variable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,6 +44,7 @@ import de.tum.cit.aet.artemis.programming.dto.CheckoutDirectoriesDTO; import de.tum.cit.aet.artemis.programming.repository.BuildPlanRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildConfigRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.service.UriService; import de.tum.cit.aet.artemis.programming.service.ci.AbstractContinuousIntegrationService; import de.tum.cit.aet.artemis.programming.service.ci.CIPermission; @@ -51,6 +59,8 @@ public class GitLabCIService extends AbstractContinuousIntegrationService { private static final String GITLAB_CI_FILE_EXTENSION = ".yml"; + private static final String GITLAB_TEST_TOKEN_NAME = "Artemis Test Token"; + private static final Logger log = LoggerFactory.getLogger(GitLabCIService.class); private static final String VARIABLE_BUILD_DOCKER_IMAGE_NAME = "ARTEMIS_BUILD_DOCKER_IMAGE"; @@ -91,6 +101,8 @@ public class GitLabCIService extends AbstractContinuousIntegrationService { private final ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository; + private final ProgrammingExerciseRepository programmingExerciseRepository; + @Value("${artemis.version-control.url}") private URL gitlabServerUrl; @@ -110,29 +122,30 @@ public class GitLabCIService extends AbstractContinuousIntegrationService { private String gitlabToken; public GitLabCIService(GitLabApi gitlab, UriService uriService, BuildPlanRepository buildPlanRepository, GitLabCIBuildPlanService buildPlanService, - ProgrammingLanguageConfiguration programmingLanguageConfiguration, ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository) { + ProgrammingLanguageConfiguration programmingLanguageConfiguration, ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, + ProgrammingExerciseRepository programmingExerciseRepository) { this.gitlab = gitlab; this.uriService = uriService; this.buildPlanRepository = buildPlanRepository; this.buildPlanService = buildPlanService; this.programmingLanguageConfiguration = programmingLanguageConfiguration; this.programmingExerciseBuildConfigRepository = programmingExerciseBuildConfigRepository; + this.programmingExerciseRepository = programmingExerciseRepository; } @Override public void createBuildPlanForExercise(ProgrammingExercise exercise, String planKey, VcsRepositoryUri repositoryUri, VcsRepositoryUri testRepositoryUri, VcsRepositoryUri solutionRepositoryUri) { - addBuildPlanToProgrammingExerciseIfUnset(exercise); - setupGitLabCIConfiguration(repositoryUri, exercise, generateBuildPlanId(exercise.getProjectKey(), planKey)); - // TODO: triggerBuild(repositoryUri, exercise.getBranch()); + addBuildPlanToProgrammingExercise(exercise, false); + // This method is called twice when creating an exercise. Once for the template repository and once for the solution repository. + // The second time, we don't want to overwrite the configuration. + setupGitLabCIConfigurationForGroup(exercise, false); + setupGitLabCIConfigurationForRepository(repositoryUri, exercise, generateBuildPlanId(exercise.getProjectKey(), planKey)); } - private void setupGitLabCIConfiguration(VcsRepositoryUri repositoryUri, ProgrammingExercise exercise, String buildPlanId) { + private void setupGitLabCIConfigurationForRepository(VcsRepositoryUri repositoryUri, ProgrammingExercise exercise, String buildPlanId) { final String repositoryPath = uriService.getRepositoryPathFromRepositoryUri(repositoryUri); - ProjectApi projectApi = gitlab.getProjectApi(); - - programmingExerciseBuildConfigRepository.loadAndSetBuildConfig(exercise); - + final ProjectApi projectApi = gitlab.getProjectApi(); try { Project project = projectApi.getProject(repositoryPath); @@ -144,40 +157,99 @@ private void setupGitLabCIConfiguration(VcsRepositoryUri repositoryUri, Programm project.setCiConfigPath(buildPlanUrl); projectApi.updateProject(project); + + setRepositoryVariableIfUnset(repositoryPath, VARIABLE_BUILD_PLAN_ID_NAME, buildPlanId); } catch (GitLabApiException e) { throw new GitLabCIException("Error enabling CI for " + repositoryUri, e); } + } + + private void setupGitLabCIConfigurationForGroup(ProgrammingExercise exercise, boolean overwrite) { + programmingExerciseBuildConfigRepository.loadAndSetBuildConfig(exercise); + + final String projectKey = exercise.getProjectKey(); + final ProgrammingExerciseBuildConfig buildConfig = exercise.getBuildConfig(); + + updateGroupVariable(projectKey, VARIABLE_BUILD_DOCKER_IMAGE_NAME, + programmingLanguageConfiguration.getImage(exercise.getProgrammingLanguage(), Optional.ofNullable(exercise.getProjectType())), overwrite); + updateGroupVariable(projectKey, VARIABLE_BUILD_LOGS_FILE_NAME, "build.log", overwrite); + // TODO: Implement the custom feedback feature + updateGroupVariable(projectKey, VARIABLE_CUSTOM_FEEDBACK_DIR_NAME, "TODO", overwrite); + updateGroupVariable(projectKey, VARIABLE_NOTIFICATION_PLUGIN_DOCKER_IMAGE_NAME, notificationPluginDockerImage, overwrite); + updateGroupVariable(projectKey, VARIABLE_NOTIFICATION_SECRET_NAME, artemisAuthenticationTokenValue, overwrite); + updateGroupVariable(projectKey, VARIABLE_NOTIFICATION_URL_NAME, artemisServerUrl.toExternalForm() + NEW_RESULT_RESOURCE_API_PATH, overwrite); + updateGroupVariable(projectKey, VARIABLE_SUBMISSION_GIT_BRANCH_NAME, buildConfig.getBranch(), overwrite); + updateGroupVariable(projectKey, VARIABLE_TEST_GIT_BRANCH_NAME, buildConfig.getBranch(), overwrite); + updateGroupVariable(projectKey, VARIABLE_TEST_GIT_REPOSITORY_SLUG_NAME, uriService.getRepositorySlugFromRepositoryUriString(exercise.getTestRepositoryUri()), overwrite); + updateGroupVariable(projectKey, VARIABLE_TEST_GIT_TOKEN, () -> generateGitLabTestToken(exercise), overwrite); + updateGroupVariable(projectKey, VARIABLE_TEST_GIT_USER, gitlabUser, overwrite); + updateGroupVariable(projectKey, VARIABLE_TEST_RESULTS_DIR_NAME, "target/surefire-reports", overwrite); + } + + private void updateGroupVariable(String projectKey, String key, String value, boolean overwrite) { + updateGroupVariable(projectKey, key, () -> value, overwrite); + } + + private void updateGroupVariable(String projectKey, String key, Supplier value, boolean overwrite) { + final GroupApi groupApi = gitlab.getGroupApi(); + if (groupApi.getOptionalVariable(projectKey, key).isEmpty()) { + try { + String valueString = value.get(); + groupApi.createVariable(projectKey, key, valueString, false, canBeMasked(valueString)); + } + catch (GitLabApiException e) { + log.error("Error creating variable '{}' for group {}", key, projectKey, e); + throw new GitLabCIException("Error creating variable '" + key + "' for group " + projectKey, e); + } + } + else if (overwrite) { + try { + String valueString = value.get(); + groupApi.updateVariable(projectKey, key, valueString, false, canBeMasked(valueString)); + } + catch (GitLabApiException e) { + log.error("Error updating variable '{}' for group {}", key, projectKey, e); + throw new GitLabCIException("Error updating variable '" + key + "' for group " + projectKey, e); + } + } + } + + private String generateGitLabTestToken(ProgrammingExercise programmingExercise) { + String testRepositoryPath = uriService.getRepositoryPathFromRepositoryUri(programmingExercise.getVcsTestRepositoryUri()); + ZonedDateTime courseEndDate = programmingExercise.getCourseViaExerciseGroupOrCourseMember().getEndDate(); + + Date expiryDate; + if (courseEndDate != null && courseEndDate.isAfter(ZonedDateTime.now())) { + expiryDate = Date.from(courseEndDate.toInstant()); + } + else { + expiryDate = Date.from(ZonedDateTime.now().plusMonths(6).toInstant()); + } + ProjectAccessToken projectAccessToken; try { - // TODO: Reduce the number of API calls - ProgrammingExerciseBuildConfig buildConfig = exercise.getBuildConfig(); - updateVariable(repositoryPath, VARIABLE_BUILD_DOCKER_IMAGE_NAME, - programmingLanguageConfiguration.getImage(exercise.getProgrammingLanguage(), Optional.ofNullable(exercise.getProjectType()))); - updateVariable(repositoryPath, VARIABLE_BUILD_LOGS_FILE_NAME, "build.log"); - updateVariable(repositoryPath, VARIABLE_BUILD_PLAN_ID_NAME, buildPlanId); - // TODO: Implement the custom feedback feature - updateVariable(repositoryPath, VARIABLE_CUSTOM_FEEDBACK_DIR_NAME, "TODO"); - updateVariable(repositoryPath, VARIABLE_NOTIFICATION_PLUGIN_DOCKER_IMAGE_NAME, notificationPluginDockerImage); - updateVariable(repositoryPath, VARIABLE_NOTIFICATION_SECRET_NAME, artemisAuthenticationTokenValue); - updateVariable(repositoryPath, VARIABLE_NOTIFICATION_URL_NAME, artemisServerUrl.toExternalForm() + NEW_RESULT_RESOURCE_API_PATH); - updateVariable(repositoryPath, VARIABLE_SUBMISSION_GIT_BRANCH_NAME, buildConfig.getBranch()); - updateVariable(repositoryPath, VARIABLE_TEST_GIT_BRANCH_NAME, buildConfig.getBranch()); - updateVariable(repositoryPath, VARIABLE_TEST_GIT_REPOSITORY_SLUG_NAME, uriService.getRepositorySlugFromRepositoryUriString(exercise.getTestRepositoryUri())); - // TODO: Use a token that is only valid for the test repository for each programming exercise - updateVariable(repositoryPath, VARIABLE_TEST_GIT_TOKEN, gitlabToken); - updateVariable(repositoryPath, VARIABLE_TEST_GIT_USER, gitlabUser); - updateVariable(repositoryPath, VARIABLE_TEST_RESULTS_DIR_NAME, "target/surefire-reports"); + projectAccessToken = gitlab.getProjectApi().createProjectAccessToken(testRepositoryPath, GITLAB_TEST_TOKEN_NAME, + List.of(Constants.ProjectAccessTokenScope.READ_REPOSITORY), expiryDate, Long.valueOf(AccessLevel.REPORTER.value)); } catch (GitLabApiException e) { - log.error("Error creating variable for {} The variables may already have been created.", repositoryUri, e); + log.error("Error creating project access token for test repository {}", testRepositoryPath, e); + throw new GitLabCIException("Error creating project access token for test repository " + testRepositoryPath, e); } + return projectAccessToken.getToken(); } - private void updateVariable(String repositoryPath, String key, String value) throws GitLabApiException { - // TODO: We can even define the variables on group level - // TODO: If the variable already exists, we should update it - gitlab.getProjectApi().createVariable(repositoryPath, key, value, Variable.Type.ENV_VAR, false, canBeMasked(value)); + private void setRepositoryVariableIfUnset(String repositoryPath, String key, String value) { + final ProjectApi projectApi = gitlab.getProjectApi(); + if (projectApi.getOptionalVariable(repositoryPath, key).isEmpty()) { + try { + projectApi.createVariable(repositoryPath, key, value, Variable.Type.ENV_VAR, false, canBeMasked(value)); + } + catch (GitLabApiException e) { + log.error("Error creating variable '{}' for repository {}", key, repositoryPath, e); + throw new GitLabCIException("Error creating variable '" + key + "' for repository " + repositoryPath, e); + } + } } private boolean canBeMasked(String value) { @@ -185,9 +257,9 @@ private boolean canBeMasked(String value) { return value != null && value.matches("^[a-zA-Z0-9+/=@:.~]{8,}$"); } - private void addBuildPlanToProgrammingExerciseIfUnset(ProgrammingExercise programmingExercise) { + private void addBuildPlanToProgrammingExercise(ProgrammingExercise programmingExercise, boolean overwrite) { Optional optionalBuildPlan = buildPlanRepository.findByProgrammingExercises_IdWithProgrammingExercises(programmingExercise.getId()); - if (optionalBuildPlan.isEmpty()) { + if (optionalBuildPlan.isEmpty() || overwrite) { var defaultBuildPlan = buildPlanService.generateDefaultBuildPlan(programmingExercise); buildPlanRepository.setBuildPlanForExercise(defaultBuildPlan, programmingExercise); } @@ -195,15 +267,15 @@ private void addBuildPlanToProgrammingExerciseIfUnset(ProgrammingExercise progra @Override public void recreateBuildPlansForExercise(ProgrammingExercise exercise) { - addBuildPlanToProgrammingExerciseIfUnset(exercise); + addBuildPlanToProgrammingExercise(exercise, true); + // When recreating the build plan for the exercise, we want to overwrite the configuration. + setupGitLabCIConfigurationForGroup(exercise, true); - VcsRepositoryUri templateUrl = exercise.getVcsTemplateRepositoryUri(); - setupGitLabCIConfiguration(templateUrl, exercise, exercise.getTemplateBuildPlanId()); - // TODO: triggerBuild(templateUrl, exercise.getBranch()); + VcsRepositoryUri templateUri = exercise.getVcsTemplateRepositoryUri(); + setupGitLabCIConfigurationForRepository(templateUri, exercise, exercise.getTemplateBuildPlanId()); - VcsRepositoryUri solutionUrl = exercise.getVcsSolutionRepositoryUri(); - setupGitLabCIConfiguration(solutionUrl, exercise, exercise.getSolutionBuildPlanId()); - // TODO: triggerBuild(solutionUrl, exercise.getBranch()); + VcsRepositoryUri solutionUri = exercise.getVcsSolutionRepositoryUri(); + setupGitLabCIConfigurationForRepository(solutionUri, exercise, exercise.getSolutionBuildPlanId()); } @Override @@ -223,19 +295,20 @@ private String generateBuildPlanId(String projectKey, String planKey) { @Override public void configureBuildPlan(ProgrammingExerciseParticipation participation, String defaultBranch) { - setupGitLabCIConfiguration(participation.getVcsRepositoryUri(), participation.getProgrammingExercise(), participation.getBuildPlanId()); + ProgrammingExercise programmingExercise = programmingExerciseRepository.findByIdWithBuildConfigElseThrow(participation.getProgrammingExercise().getId()); + setupGitLabCIConfigurationForRepository(participation.getVcsRepositoryUri(), programmingExercise, participation.getBuildPlanId()); } @Override public void deleteProject(String projectKey) { - log.error("Unsupported action: GitLabCIService.deleteBuildPlan()"); - log.error("Please refer to the repository for deleting the project. The build plan can not be deleted separately."); + log.debug("Unsupported action: GitLabCIService.deleteBuildPlan()"); + log.debug("Please refer to the repository for deleting the project. The build plan can not be deleted separately."); } @Override public void deleteBuildPlan(String projectKey, String buildPlanId) { - log.error("Unsupported action: GitLabCIService.deleteBuildPlan()"); - log.error("Please refer to the repository for deleting the project. The build plan can not be deleted separately."); + log.debug("Unsupported action: GitLabCIService.deleteBuildPlan()"); + log.debug("Please refer to the repository for deleting the project. The build plan can not be deleted separately."); } @Override @@ -278,46 +351,46 @@ private Optional getLatestPipeline(final ProgrammingExerciseParticipat @Override public boolean checkIfBuildPlanExists(String projectKey, String buildPlanId) { - log.error("Unsupported action: GitLabCIService.checkIfBuildPlanExists()"); + log.debug("Unsupported action: GitLabCIService.checkIfBuildPlanExists()"); return true; } @Override public ResponseEntity retrieveLatestArtifact(ProgrammingExerciseParticipation participation) { - log.error("Unsupported action: GitLabCIService.retrieveLatestArtifact()"); + log.debug("Unsupported action: GitLabCIService.retrieveLatestArtifact()"); return null; } @Override public String checkIfProjectExists(String projectKey, String projectName) { - log.error("Unsupported action: GitLabCIService.checkIfProjectExists()"); + log.debug("Unsupported action: GitLabCIService.checkIfProjectExists()"); return null; } @Override public void enablePlan(String projectKey, String planKey) { - log.error("Unsupported action: GitLabCIService.enablePlan()"); + log.debug("Unsupported action: GitLabCIService.enablePlan()"); } @Override public void updatePlanRepository(String buildProjectKey, String buildPlanKey, String ciRepoName, String repoProjectKey, String newRepoUri, String existingRepoUri, String newDefaultBranch) { - log.error("Unsupported action: GitLabCIService.updatePlanRepository()"); + log.debug("Unsupported action: GitLabCIService.updatePlanRepository()"); } @Override public void giveProjectPermissions(String projectKey, List groups, List permissions) { - log.error("Unsupported action: GitLabCIService.giveProjectPermissions()"); + log.debug("Unsupported action: GitLabCIService.giveProjectPermissions()"); } @Override public void givePlanPermissions(ProgrammingExercise programmingExercise, String planName) { - log.error("Unsupported action: GitLabCIService.givePlanPermissions()"); + log.debug("Unsupported action: GitLabCIService.givePlanPermissions()"); } @Override public void removeAllDefaultProjectPermissions(String projectKey) { - log.error("Unsupported action: GitLabCIService.removeAllDefaultProjectPermissions()"); + log.debug("Unsupported action: GitLabCIService.removeAllDefaultProjectPermissions()"); } @Override @@ -327,12 +400,12 @@ public ConnectorHealth health() { @Override public void createProjectForExercise(ProgrammingExercise programmingExercise) throws ContinuousIntegrationException { - log.error("Unsupported action: GitLabCIService.createProjectForExercise()"); + log.debug("Unsupported action: GitLabCIService.createProjectForExercise()"); } @Override public Optional getWebHookUrl(String projectKey, String buildPlanId) { - log.error("Unsupported action: GitLabCIService.getWebHookUrl()"); + log.debug("Unsupported action: GitLabCIService.getWebHookUrl()"); return Optional.empty(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIUserManagementService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIUserManagementService.java index 19f37bed1334..10bec4e5981f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIUserManagementService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIUserManagementService.java @@ -63,6 +63,6 @@ public void updateCoursePermissions(Course updatedCourse, String oldInstructorGr } private void logUnsupportedAction() { - log.error("Please refer to the repository for user management."); + log.debug("Please refer to the repository for user management."); } } diff --git a/src/main/resources/templates/gitlabci/empty/regularRuns/.gitlab-ci.yml b/src/main/resources/templates/gitlabci/empty/regularRuns/.gitlab-ci.yml index 3a16b66b7143..1c5857d1767a 100644 --- a/src/main/resources/templates/gitlabci/empty/regularRuns/.gitlab-ci.yml +++ b/src/main/resources/templates/gitlabci/empty/regularRuns/.gitlab-ci.yml @@ -8,13 +8,14 @@ test-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH - allow_failure: true + refs: + - triggers variables: GIT_STRATEGY: none MAVEN_OPTS: -Dorg.slf4j.simpleLogger.showDateTime=true -Dorg.slf4j.simpleLogger.dateTimeFormat=[yyyy-MM-dd'T'HH:mm:ssX] -Dorg.slf4j.simpleLogger.logFile=${ARTEMIS_BUILD_LOGS_FILE} script: - git clone --branch ${ARTEMIS_TEST_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${ARTEMIS_TEST_GIT_REPOSITORY_SLUG} . - - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment + - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment - export ARTEMIS_NOTIFICATION_SECRET=[hidden] # Workaround for overwriting the secret - export ARTEMIS_TEST_GIT_TOKEN=[hidden] # TODO: Install dependencies not provided by the Docker image @@ -41,6 +42,8 @@ upload-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH + refs: + - triggers variables: GIT_STRATEGY: none script: diff --git a/src/main/resources/templates/gitlabci/java/maven/regularRuns/.gitlab-ci.yml b/src/main/resources/templates/gitlabci/java/maven/regularRuns/.gitlab-ci.yml index 7e5b429dd5f8..e71e7961d5bd 100644 --- a/src/main/resources/templates/gitlabci/java/maven/regularRuns/.gitlab-ci.yml +++ b/src/main/resources/templates/gitlabci/java/maven/regularRuns/.gitlab-ci.yml @@ -8,13 +8,14 @@ test-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH - allow_failure: true + refs: + - triggers variables: GIT_STRATEGY: none MAVEN_OPTS: -Dorg.slf4j.simpleLogger.showDateTime=true -Dorg.slf4j.simpleLogger.dateTimeFormat=[yyyy-MM-dd'T'HH:mm:ssX] -Dorg.slf4j.simpleLogger.logFile=${ARTEMIS_BUILD_LOGS_FILE} script: - git clone --branch ${ARTEMIS_TEST_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${ARTEMIS_TEST_GIT_REPOSITORY_SLUG} . - - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment + - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment - export ARTEMIS_NOTIFICATION_SECRET=[hidden] # Workaround for overwriting the secret - export ARTEMIS_TEST_GIT_TOKEN=[hidden] - mvn test -B && echo "ARTEMIS_BUILD_STATUS=success" > .env || echo "ARTEMIS_BUILD_STATUS=failed" > .env @@ -38,6 +39,8 @@ upload-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH + refs: + - triggers variables: GIT_STRATEGY: none script: diff --git a/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml b/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml index 39e416158a9c..d5f4a007ca2d 100644 --- a/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml +++ b/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml @@ -9,12 +9,13 @@ test-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH - allow_failure: true + refs: + - triggers variables: GIT_STRATEGY: none script: - git clone --branch ${ARTEMIS_TEST_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${ARTEMIS_TEST_GIT_REPOSITORY_SLUG} . - - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment + - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment - export ARTEMIS_NOTIFICATION_SECRET=[hidden] # Workaround for overwriting the secret - export ARTEMIS_TEST_GIT_TOKEN=[hidden] - cargo nextest run --profile ci | tee -a "${ARTEMIS_BUILD_LOGS_FILE}" && echo "ARTEMIS_BUILD_STATUS=success" > .env || echo "ARTEMIS_BUILD_STATUS=failed" > .env @@ -39,6 +40,8 @@ upload-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH + refs: + - triggers variables: GIT_STRATEGY: none script: diff --git a/src/test/java/de/tum/cit/aet/artemis/core/connector/GitlabRequestMockProvider.java b/src/test/java/de/tum/cit/aet/artemis/core/connector/GitlabRequestMockProvider.java index 8c23c2389c41..6ac2c36c00db 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/connector/GitlabRequestMockProvider.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/connector/GitlabRequestMockProvider.java @@ -4,6 +4,7 @@ import static org.gitlab4j.api.models.AccessLevel.GUEST; import static org.gitlab4j.api.models.AccessLevel.MAINTAINER; import static org.gitlab4j.api.models.AccessLevel.REPORTER; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyBoolean; import static org.mockito.Mockito.anyLong; @@ -60,6 +61,7 @@ import org.gitlab4j.api.models.PipelineFilter; import org.gitlab4j.api.models.PipelineStatus; import org.gitlab4j.api.models.Project; +import org.gitlab4j.api.models.ProjectAccessToken; import org.gitlab4j.api.models.ProjectHook; import org.gitlab4j.api.models.ProtectedBranch; import org.gitlab4j.api.models.PushData; @@ -919,6 +921,15 @@ public void mockUpdateProject(boolean shouldFail) throws GitLabApiException { } } + public void mockCreateProjectAccessToken(boolean shouldFail) throws GitLabApiException { + if (shouldFail) { + doThrow(new GitLabApiException("Internal Error", 500)).when(projectApi).createProjectAccessToken(anyString(), anyString(), anyList(), any(), anyLong()); + } + else { + doReturn(new ProjectAccessToken()).when(projectApi).createProjectAccessToken(anyString(), anyString(), anyList(), any(), anyLong()); + } + } + public void mockGetBuildStatus(PipelineStatus pipelineStatus) throws GitLabApiException { Pipeline pipeline = new Pipeline(); pipeline.setId(1L); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/service/GitlabCIServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/service/GitlabCIServiceTest.java index fda58121a3ac..3321f9add124 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/service/GitlabCIServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/service/GitlabCIServiceTest.java @@ -171,9 +171,18 @@ void testTriggerBuildFails() throws GitLabApiException { void testConfigureBuildPlanSuccess() throws Exception { final ProgrammingExercise exercise = programmingExerciseRepository.findWithBuildConfigById(programmingExerciseId).orElseThrow(); final ProgrammingExerciseStudentParticipation participation = participationUtilService.addStudentParticipationForProgrammingExercise(exercise, TEST_PREFIX + "student1"); + final String repositoryPath = uriService.getRepositoryPathFromRepositoryUri(participation.getVcsRepositoryUri()); mockConfigureBuildPlan(participation, defaultBranch); + continuousIntegrationService.configureBuildPlan(participation, defaultBranch); + verifyMocks(); + verify(gitlab, atLeastOnce()).getProjectApi(); + verify(gitlab.getProjectApi(), atLeastOnce()).getProject(eq(repositoryPath)); + verify(gitlab.getProjectApi(), atLeastOnce()).updateProject(any(Project.class)); + verify(gitlab.getProjectApi(), atLeastOnce()).getOptionalVariable(eq(repositoryPath), anyString()); + verify(gitlab.getProjectApi(), atLeastOnce()).createVariable(eq(repositoryPath), anyString(), anyString(), any(), anyBoolean(), anyBoolean()); + verify(gitlab.getGroupApi(), never()).createVariable(anyString(), anyString(), anyString(), anyBoolean(), anyBoolean()); } @Test @@ -198,9 +207,16 @@ void testCreateBuildPlanForExercise() throws GitLabApiException { continuousIntegrationService.createBuildPlanForExercise(exercise, "TEST-EXERCISE", participation.getVcsRepositoryUri(), null, null); verify(gitlab, atLeastOnce()).getProjectApi(); + verify(gitlab, atLeastOnce()).getGroupApi(); + verify(gitlab.getProjectApi(), atLeastOnce()).getProject(eq(repositoryPath)); verify(gitlab.getProjectApi(), atLeastOnce()).updateProject(any(Project.class)); + verify(gitlab.getProjectApi(), atLeastOnce()).getOptionalVariable(any(), anyString()); verify(gitlab.getProjectApi(), atLeastOnce()).createVariable(anyString(), anyString(), anyString(), any(), anyBoolean(), anyBoolean()); + + verify(gitlab.getGroupApi(), atLeastOnce()).getOptionalVariable(any(), anyString()); + verify(gitlab.getGroupApi(), atLeastOnce()).createVariable(anyString(), anyString(), anyString(), anyBoolean(), anyBoolean()); + var buildPlanOptional = buildPlanRepository.findByProgrammingExercises_IdWithProgrammingExercises(exercise.getId()); assertThat(buildPlanOptional).isPresent(); assertThat(buildPlanOptional.get().getBuildPlan()).isNotBlank(); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTestRepository.java b/src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTestRepository.java index dba45e8f6298..19c04aac1ffa 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTestRepository.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTestRepository.java @@ -7,8 +7,6 @@ import java.util.Optional; import java.util.Set; -import jakarta.validation.constraints.NotNull; - import org.hibernate.Hibernate; import org.springframework.context.annotation.Primary; import org.springframework.data.jpa.repository.EntityGraph; @@ -16,7 +14,6 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; @@ -90,11 +87,6 @@ default List findAllWithBuildAndTestAfterDueDateInFuture() List findAllByCourse_TeachingAssistantGroupNameIn(Set groupNames); - @NotNull - default ProgrammingExercise findByIdWithBuildConfigElseThrow(long programmingExerciseId) throws EntityNotFoundException { - return getValueElseThrow(findWithBuildConfigById(programmingExerciseId), programmingExerciseId); - } - @EntityGraph(type = LOAD, attributePaths = { "templateParticipation", "solutionParticipation", "studentParticipations.team.students", "buildConfig" }) Optional findWithAllParticipationsAndBuildConfigById(long exerciseId); @@ -107,9 +99,6 @@ default ProgrammingExercise findByIdWithBuildConfigElseThrow(long programmingExe """) Optional findWithEagerTemplateAndSolutionParticipationsById(@Param("exerciseId") long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "buildConfig" }) - Optional findWithBuildConfigById(long exerciseId); - /** * Fetch the programming exercise with the build config, or throw an EntityNotFoundException if it cannot be found. * diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationGitlabCIGitlabSamlTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationGitlabCIGitlabSamlTest.java index 1f78277f6942..9f33287aabbe 100644 --- a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationGitlabCIGitlabSamlTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationGitlabCIGitlabSamlTest.java @@ -326,6 +326,7 @@ public void mockConfigureBuildPlan(ProgrammingExerciseParticipation participatio public void mockAddBuildPlanToGitLabRepositoryConfiguration(boolean shouldFail) throws GitLabApiException { gitlabRequestMockProvider.mockGetProject(shouldFail); gitlabRequestMockProvider.mockUpdateProject(shouldFail); + gitlabRequestMockProvider.mockCreateProjectAccessToken(shouldFail); } @Override From e13b4a81fc476c92bdc85cabc32c90eb3fe53f85 Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Sun, 27 Oct 2024 23:33:04 +0100 Subject: [PATCH 13/13] Integrated code lifecycle: Insert repository content consistently when preparing for building (#9521) --- .../service/BuildJobContainerService.java | 18 ++++++++++++------ ...ramming-exercise-information.component.scss | 4 ++++ .../webapp/i18n/de/programmingExercise.json | 2 +- .../webapp/i18n/en/programmingExercise.json | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java index b7c97daa9786..5d4c72d5b491 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java @@ -300,16 +300,17 @@ public void populateBuildJobContainer(String buildJobContainerId, Path assignmen executeDockerCommand(buildJobContainerId, null, false, false, true, "chmod", "-R", "777", LOCALCI_WORKING_DIRECTORY + "/testing-dir"); // Copy the test repository to the container and move it to the test checkout path (may be the working directory) - addAndPrepareDirectory(buildJobContainerId, testRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + testCheckoutPath); + addAndPrepareDirectoryAndReplaceContent(buildJobContainerId, testRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + testCheckoutPath); // Copy the assignment repository to the container and move it to the assignment checkout path - addAndPrepareDirectory(buildJobContainerId, assignmentRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + assignmentCheckoutPath); + addAndPrepareDirectoryAndReplaceContent(buildJobContainerId, assignmentRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + assignmentCheckoutPath); if (solutionRepositoryPath != null) { solutionCheckoutPath = (!StringUtils.isBlank(solutionCheckoutPath)) ? solutionCheckoutPath : RepositoryCheckoutPath.SOLUTION.forProgrammingLanguage(programmingLanguage); - addAndPrepareDirectory(buildJobContainerId, solutionRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + solutionCheckoutPath); + addAndPrepareDirectoryAndReplaceContent(buildJobContainerId, solutionRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + solutionCheckoutPath); } for (int i = 0; i < auxiliaryRepositoriesPaths.length; i++) { - addAndPrepareDirectory(buildJobContainerId, auxiliaryRepositoriesPaths[i], LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + auxiliaryRepositoryCheckoutDirectories[i]); + addAndPrepareDirectoryAndReplaceContent(buildJobContainerId, auxiliaryRepositoriesPaths[i], + LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + auxiliaryRepositoryCheckoutDirectories[i]); } createScriptFile(buildJobContainerId); @@ -320,12 +321,17 @@ private void createScriptFile(String buildJobContainerId) { executeDockerCommand(buildJobContainerId, null, false, false, true, "bash", "-c", "chmod +x " + LOCALCI_WORKING_DIRECTORY + "/script.sh"); } - private void addAndPrepareDirectory(String containerId, Path repositoryPath, String newDirectoryName) { + private void addAndPrepareDirectoryAndReplaceContent(String containerId, Path repositoryPath, String newDirectoryName) { copyToContainer(repositoryPath.toString(), containerId); - addDirectory(containerId, getParentFolderPath(newDirectoryName), true); + addDirectory(containerId, newDirectoryName, true); + removeDirectoryAndFiles(containerId, newDirectoryName); renameDirectoryOrFile(containerId, LOCALCI_WORKING_DIRECTORY + "/" + repositoryPath.getFileName().toString(), newDirectoryName); } + private void removeDirectoryAndFiles(String containerId, String newName) { + executeDockerCommand(containerId, null, false, false, true, "rm", "-rf", newName); + } + private void renameDirectoryOrFile(String containerId, String oldName, String newName) { executeDockerCommand(containerId, null, false, false, true, "mv", oldName, newName); } diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-information.component.scss b/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-information.component.scss index 76bf7863f5c5..47462abdee4e 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-information.component.scss +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-information.component.scss @@ -1,3 +1,7 @@ #creation-config-selector { position: relative; } + +::ng-deep .tooltip-inner { + max-width: 600px; +} diff --git a/src/main/webapp/i18n/de/programmingExercise.json b/src/main/webapp/i18n/de/programmingExercise.json index f26d5955a9ef..733fdeaaf120 100644 --- a/src/main/webapp/i18n/de/programmingExercise.json +++ b/src/main/webapp/i18n/de/programmingExercise.json @@ -535,7 +535,7 @@ "auxiliaryRepository": { "error": "Es gibt ein Problem mit den Hilfs-Repositories!", "addAuxiliaryRepository": "Hilfs-Repository anlegen", - "usageDescription": "Du kannst Hilfsrepositorys verwenden, um zusätzlichen Code bereitzustellen, den Studierende nicht sehen oder ändern können. Der zusätzliche Code wird während des Builds der Abgabe am angegebenen Checkout-Verzeichnis eingefügt.", + "usageDescription": "Du kannst Hilfsrepositorien verwenden, um zusätzlichen Code bereitzustellen, den die Studierenden nicht sehen oder ändern können. Der zusätzliche Code wird im angegebenen Checkout-Verzeichnis eingefügt, bevor der Build erstellt wird. Der eingefügte Code überschreibt alles, was sich an der durch das Checkout-Verzeichnis angegebenen Stelle befindet. Wenn du die Dateien der Studierenden nur teilweise überschreiben willst, muss das Build-Skript angepasst werden.", "repositoryName": "Name des Repositorys", "checkoutDirectory": "Checkout-Verzeichnis", "description": "Beschreibung", diff --git a/src/main/webapp/i18n/en/programmingExercise.json b/src/main/webapp/i18n/en/programmingExercise.json index 7659f48e78b2..ff251a44ef57 100644 --- a/src/main/webapp/i18n/en/programmingExercise.json +++ b/src/main/webapp/i18n/en/programmingExercise.json @@ -535,7 +535,7 @@ "auxiliaryRepository": { "error": "There is a problem with the auxiliary repository.", "addAuxiliaryRepository": "Add Auxiliary Repository", - "usageDescription": "You can use auxiliary repositories to provide additional code that students cannot see or modify. The additional code is inserted at the specified Checkout Directory during the build of the submission.", + "usageDescription": "You can use auxiliary repositories to provide additional code that students cannot see or modify. The additional code is inserted into the specified checkout directory before the submission is built. The inserted code overwrites everything which lies at the location specified by the checkout directory. If you only need to overwrite student files partially, you need to adapt the build script.", "repositoryName": "Repository Name", "checkoutDirectory": "Checkout Directory", "description": "Description",