diff --git a/src/api/document.ts b/src/api/document.ts index b38a01558..1f9d65e3e 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -18,6 +18,7 @@ import type DocumentStore from '@tdev-stores/DocumentStore'; import iDocumentContainer from '@tdev-models/iDocumentContainer'; import iViewStore from '@tdev-stores/ViewStores/iViewStore'; import Code from '@tdev-models/documents/Code'; +import ChoiceAnswer, { ChoiceAnswerChoices } from '@tdev-models/documents/ChoiceAnswer'; export enum Access { RO_DocumentRoot = 'RO_DocumentRoot', @@ -40,6 +41,10 @@ export interface StringData { text: string; } +export interface ChoiceAnswerData { + choices: ChoiceAnswerChoices; +} + export interface QuillV2Data { delta: Delta; } @@ -119,6 +124,7 @@ export interface TypeDataMapping extends ContainerTypeDataMapping { // TODO: rename to `code_version`? ['script_version']: ScriptVersionData; ['string']: StringData; + ['choice_answer']: ChoiceAnswerData; ['quill_v2']: QuillV2Data; ['solution']: SolutionData; ['dir']: DirData; @@ -148,6 +154,7 @@ export interface TypeModelMapping extends ContainerTypeModelMapping { // TODO: rename to `code_version`? ['script_version']: ScriptVersion; ['string']: String; + ['choice_answer']: ChoiceAnswer; ['quill_v2']: QuillV2; ['solution']: Solution; ['dir']: Directory; diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx new file mode 100644 index 000000000..01d772c2c --- /dev/null +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -0,0 +1,119 @@ +import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; +import { ModelMeta } from '@tdev-models/documents/ChoiceAnswer'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; + +interface ChoiceAnswerProps { + id: string; + questionIndex?: number; + multiple?: boolean; + readonly?: boolean; + children: React.ReactNode; +} + +interface ThinWrapperProps { + children: React.ReactNode; +} + +interface OptionProps { + children: React.ReactNode; + optionIndex: number; +} + +type ChoiceAnswerSubComponents = { + Before: React.FC; + Options: React.FC; + Option: React.FC; + After: React.FC; +}; + +const ChoiceAnswerContext = React.createContext({ + id: '', + multiple: false, + readonly: false, + selectedChoices: [], + onChange: () => {} +} as { + id: string; + multiple?: boolean; + readonly?: boolean; + selectedChoices: number[]; + onChange: (optionIndex: number, checked: boolean) => void; +}); + +const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { + const [meta] = React.useState(new ModelMeta(props)); + const doc = useFirstMainDocument(props.id, meta); + const questionIndex = props.questionIndex ?? 0; + + const childrenArray = React.Children.toArray(props.children); + const beforeBlock = childrenArray.find( + (child) => React.isValidElement(child) && child.type === ChoiceAnswer.Before + ); + const optionsBlock = childrenArray.find( + (child) => React.isValidElement(child) && child.type === ChoiceAnswer.Options + ); + const afterBlock = childrenArray.find( + (child) => React.isValidElement(child) && child.type === ChoiceAnswer.After + ); + + const onOptionChange = (optionIndex: number, checked: boolean) => { + if (props.multiple) { + doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked); + } else { + doc?.updateSingleChoiceSelection(questionIndex, optionIndex); + } + }; + + return ( +
+ {beforeBlock} + + {optionsBlock} + + {afterBlock} +
+ ); +}) as React.FC & ChoiceAnswerSubComponents; + +ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { + const parentProps = React.useContext(ChoiceAnswerContext); + const optionId = `${parentProps.id}-option-${optionIndex}`; + + return ( +
+ parentProps.onChange(optionIndex, e.target.checked)} + checked={parentProps.selectedChoices.includes(optionIndex)} + disabled={parentProps.readonly} + /> + +
+ ); +}; + +ChoiceAnswer.Before = ({ children }: { children: React.ReactNode }) => { + return <>{children}; +}; +ChoiceAnswer.Options = ({ children }: { children: React.ReactNode }) => { + return <>{children}; +}; +ChoiceAnswer.After = ({ children }: { children: React.ReactNode }) => { + return <>{children}; +}; + +export default ChoiceAnswer; diff --git a/src/components/documents/ChoiceAnswer/styles.module.scss b/src/components/documents/ChoiceAnswer/styles.module.scss new file mode 100644 index 000000000..c376315aa --- /dev/null +++ b/src/components/documents/ChoiceAnswer/styles.module.scss @@ -0,0 +1,13 @@ +.choiceAnswerOptionContainer { + display: flex; + flex-direction: row; + align-items: center; + + p { + margin: 0; + } + + label { + margin-left: 0.2em; + } +} diff --git a/src/components/documents/ProgressState/index.tsx b/src/components/documents/ProgressState/index.tsx index babbd11b1..defbde16e 100644 --- a/src/components/documents/ProgressState/index.tsx +++ b/src/components/documents/ProgressState/index.tsx @@ -8,6 +8,7 @@ import Item from './Item'; import { useStore } from '@tdev-hooks/useStore'; import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType'; +import { extractListItems } from '@tdev-components/util/domHelpers'; interface Props extends MetaInit { id: string; float?: 'left' | 'right'; @@ -15,59 +16,11 @@ interface Props extends MetaInit { labels?: React.ReactNode[]; } -const useExtractedChildren = (children: React.ReactElement): React.ReactNode[] | null => { - const liContent = React.useMemo(() => { - if (!children) { - return null; - } - /** - * Extracts the children of the first
    element. - *
      - *
    1. Item 1
    2. - *
    3. Item 2
    4. - *
    - * Is represented as: - * ```js - * { - * type: 'ol', - * props: { - * children: [ - * { - * type: 'li', - * props: { children: 'Item 1' }, - * }, - * { - * type: 'li', - * props: { children: 'Item 2' }, - * }, - * ] - * } - * } - * ``` - * Use the `children.props.children` to access the nested `
  1. ` elements, but don't enforce - * that the root element is an `
      `, as it might be a custom component that renders an `
        ` - * internally. Like that, e.g. `
          ` is supported as well (where Docusaurus uses an `MDXUl` Component...). - */ - const nestedChildren = (children.props as any)?.children; - if (Array.isArray(nestedChildren)) { - return nestedChildren - .filter((c: any) => typeof c === 'object' && c !== null && c.props?.children) - .map((c: any) => { - return c.props.children as React.ReactNode; - }); - } - throw new Error( - `ProgressState must have an
            as a child, found ${typeof children.type === 'function' ? children.type.name : children.type}` - ); - }, [children]); - return liContent; -}; - const ProgressState = observer((props: Props) => { const [meta] = React.useState(new ModelMeta(props)); const pageStore = useStore('pageStore'); const doc = useFirstMainDocument(props.id, meta); - const children = useExtractedChildren(props.children as React.ReactElement); + const children = extractListItems(props.children as React.ReactElement); React.useEffect(() => { doc?.setTotalSteps(children?.length || 0); }, [doc, children?.length]); diff --git a/src/components/util/domHelpers.ts b/src/components/util/domHelpers.ts new file mode 100644 index 000000000..95c3d9aad --- /dev/null +++ b/src/components/util/domHelpers.ts @@ -0,0 +1,49 @@ +import React from "react"; + +export const extractListItems = (children: React.ReactElement): React.ReactNode[] | null => { + const liContent = React.useMemo(() => { + if (!children) { + return null; + } + /** + * Extracts the children of the first
              element. + *
                + *
              1. Item 1
              2. + *
              3. Item 2
              4. + *
              + * Is represented as: + * ```js + * { + * type: 'ol', + * props: { + * children: [ + * { + * type: 'li', + * props: { children: 'Item 1' }, + * }, + * { + * type: 'li', + * props: { children: 'Item 2' }, + * }, + * ] + * } + * } + * ``` + * Use the `children.props.children` to access the nested `
            1. ` elements, but don't enforce + * that the root element is an `
                `, as it might be a custom component that renders an `
                  ` + * internally. Like that, e.g. `
                    ` is supported as well (where Docusaurus uses an `MDXUl` Component...). + */ + const nestedChildren = (children.props as any)?.children; + if (Array.isArray(nestedChildren)) { + return nestedChildren + .filter((c: any) => typeof c === 'object' && c !== null && c.props?.children) + .map((c: any) => { + return c.props.children as React.ReactNode; + }); + } + throw new Error( + `ProgressState must have an
                      as a child, found ${typeof children.type === 'function' ? children.type.name : children.type}` + ); + }, [children]); + return liContent; +}; \ No newline at end of file diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts new file mode 100644 index 000000000..4c6532b20 --- /dev/null +++ b/src/models/documents/ChoiceAnswer.ts @@ -0,0 +1,91 @@ +import { TypeDataMapping, Document as DocumentProps, Access } from '@tdev-api/document'; +import { TypeMeta } from '@tdev-models/DocumentRoot'; +import iDocument, { Source } from '@tdev-models/iDocument'; +import DocumentStore from '@tdev-stores/DocumentStore'; +import { action, computed, observable } from 'mobx'; + +export interface ChoiceAnswerChoices { + [type: number]: number[]; +} + +export interface MetaInit { + readonly?: boolean; +} + +export class ModelMeta extends TypeMeta<'choice_answer'> { + readonly type = 'choice_answer'; + readonly readonly?: boolean; + + constructor(props: Partial) { + super('choice_answer', props.readonly ? Access.RO_User : undefined); + this.readonly = props.readonly; + } + + get defaultData(): TypeDataMapping['choice_answer'] { + return { + choices: {} + }; + } +} + +class ChoiceAnswer extends iDocument<'choice_answer'> { + @observable.ref accessor choices: ChoiceAnswerChoices; + + constructor(props: DocumentProps<'choice_answer'>, store: DocumentStore) { + super(props, store); + this.choices = props.data?.choices || {}; + } + + @action + setData(data: TypeDataMapping['choice_answer'], from: Source, updatedAt?: Date): void { + this.choices = data.choices; + if (from === Source.LOCAL) { + this.save(); + } + if (updatedAt) { + this.updatedAt = new Date(updatedAt); + } + } + + @action + updateSingleChoiceSelection(questionIndex: number, optionIndex: number): void { + this.updatedAt = new Date(); + this.choices = { + ...this.choices, + [questionIndex]: [optionIndex] + }; + this.saveNow(); + } + + @action + updateMultipleChoiceSelection(questionIndex: number, optionIndex: number, selected: boolean): void { + this.updatedAt = new Date(); + const currentSelections = new Set(this.choices[questionIndex] as number[] | []); + if (selected) { + currentSelections.add(optionIndex); + } else { + currentSelections.delete(optionIndex); + } + this.choices = { + ...this.choices, + [questionIndex]: Array.from(currentSelections) + }; + this.saveNow(); + } + + get data(): TypeDataMapping['choice_answer'] { + return { + choices: this.choices + }; + } + + @computed + get meta(): ModelMeta { + if (this.root?.type === 'choice_answer') { + return this.root.meta as ModelMeta; + } + return new ModelMeta({}); + } +} + +export default ChoiceAnswer; diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts new file mode 100644 index 000000000..0d95ebe2a --- /dev/null +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -0,0 +1,88 @@ +import { visit } from 'unist-util-visit'; +import type { Plugin } from 'unified'; +import type { Root, BlockContent, DefinitionContent } from 'mdast'; +import type { MdxJsxFlowElement } from 'mdast-util-mdx'; + +enum ChoiceComponentTypes { + ChoiceAnswer = 'ChoiceAnswer', + TrueFalseAnswer = 'TrueFalseAnswer' +} + +type FlowChildren = (BlockContent | DefinitionContent)[]; + +function createWrapper(name: string, children: FlowChildren, attributes: any[] = []): MdxJsxFlowElement { + return { + type: 'mdxJsxFlowElement', + name, + attributes: attributes.map((attr) => ({ + type: 'mdxJsxAttribute', + name: attr.name, + value: attr.value + })), + children + }; +} + +const transformOptions = (listChildren: {type: string, children: FlowChildren}[]): MdxJsxFlowElement => { + const options = listChildren + .filter((child) => child.type === 'listItem') + .map((child, index) => { + return createWrapper('ChoiceAnswer.Option', child.children, [{ name: 'optionIndex', value: index }]); + }); + + return createWrapper('ChoiceAnswer.Options', options); +} + +const plugin: Plugin<[], Root> = function choiceAnswerWrapPlugin() { + return (tree) => { + visit(tree, 'mdxJsxFlowElement', (node) => { + if ( + !node.name || + !Object.values(ChoiceComponentTypes).includes(node.name as ChoiceComponentTypes) + ) { + return; + } + + const listIndex = node.children.findIndex((child) => child.type === 'list'); + + if (listIndex === -1) { + if (node.name !== ChoiceComponentTypes.TrueFalseAnswer) { + console.warn(`No list found in <${node.name}>. Expected exactly one list child.`); + } + return; + } + + if (node.children.filter((child) => child.type === 'list').length > 1) { + console.warn(`Multiple lists found in <${node.name}>. Only the first one is used.`); + } + + const beforeChildren = node.children.slice(0, listIndex) as FlowChildren; + + const listChild = node.children[listIndex] as {children: FlowChildren}; + + const afterChildren = node.children.slice(listIndex + 1) as FlowChildren; + + const wrappedChildren: FlowChildren = []; + + if (beforeChildren.length > 0) { + wrappedChildren.push(createWrapper(`${node.name}.Before`, beforeChildren)); + } + + /* + TODO: + - Wrap each
                    1. individually in ChoiceAnswer.Option + - Enumerate the
                    2. elements during transformation, pass as index prop to ChoiceAnswer.Option + - Get rid of the
                        , put (transformed) children directly into ChoiceAnswer.Options + */ + wrappedChildren.push(transformOptions(listChild.children)); + + if (afterChildren.length > 0) { + wrappedChildren.push(createWrapper(`${node.name}.After`, afterChildren)); + } + + node.children = wrappedChildren; + }); + }; +}; + +export default plugin; diff --git a/src/siteConfig/markdownPluginConfigs.ts b/src/siteConfig/markdownPluginConfigs.ts index 2c62430f8..09f2f7082 100644 --- a/src/siteConfig/markdownPluginConfigs.ts +++ b/src/siteConfig/markdownPluginConfigs.ts @@ -18,6 +18,7 @@ import pdfPlugin from '@tdev/remark-pdf/remark-plugin'; import codeAsAttributePlugin from '../plugins/remark-code-as-attribute/plugin'; import commentPlugin from '../plugins/remark-comments/plugin'; import enumerateAnswersPlugin from '../plugins/remark-enumerate-components/plugin'; +import transformChoiceAnswerPlugin from '../plugins/remark-transform-choice-answer/plugin'; export const flexCardsPluginConfig = [ flexCardsPlugin, @@ -153,6 +154,8 @@ export const linkAnnotationPluginConfig = [ } ]; +export const transformChoiceAnswerPluginConfig = transformChoiceAnswerPlugin; + export const rehypeKatexPluginConfig = rehypeKatex; export const recommendedBeforeDefaultRemarkPlugins = [ @@ -175,7 +178,8 @@ export const recommendedRemarkPlugins = [ pagePluginConfig, commentPluginConfig, linkAnnotationPluginConfig, - codeAsAttributePluginConfig + codeAsAttributePluginConfig, + transformChoiceAnswerPluginConfig ]; export const recommendedRehypePlugins = [rehypeKatexPluginConfig]; diff --git a/src/stores/DocumentStore.ts b/src/stores/DocumentStore.ts index 8e2a33a11..942adb85f 100644 --- a/src/stores/DocumentStore.ts +++ b/src/stores/DocumentStore.ts @@ -35,6 +35,7 @@ import ProgressState from '@tdev-models/documents/ProgressState'; import Script from '@tdev-models/documents/Code'; import TaskState from '@tdev-models/documents/TaskState'; import Code from '@tdev-models/documents/Code'; +import ChoiceAnswer from '@tdev-models/documents/ChoiceAnswer'; const IsNotUniqueError = (error: any) => { try { @@ -60,6 +61,8 @@ export function CreateDocumentModel(data: DocumentProps, store: Do return new ScriptVersion(data as DocumentProps<'script_version'>, store); case 'string': return new String(data as DocumentProps<'string'>, store); + case 'choice_answer': + return new ChoiceAnswer(data as DocumentProps<'choice_answer'>, store); case 'quill_v2': return new QuillV2(data as DocumentProps<'quill_v2'>, store); case 'solution': @@ -87,6 +90,7 @@ const FactoryDefault: [DocumentType, Factory][] = [ ['progress_state', CreateDocumentModel], ['script_version', CreateDocumentModel], ['string', CreateDocumentModel], + ['choice_answer', CreateDocumentModel], ['quill_v2', CreateDocumentModel], ['solution', CreateDocumentModel], ['dir', CreateDocumentModel], diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx new file mode 100644 index 000000000..7c0f78628 --- /dev/null +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -0,0 +1,160 @@ +--- +page_id: 08ac6803-b890-4608-9d4e-28f334addfb0 +tags: + - persistable +--- + +import PermissionsPanel from "@tdev-components/PermissionsPanel" +import BrowserWindow from '@tdev-components/BrowserWindow'; +import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer'; + +# Choice Answer +Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeignet für Aufgaben, Quizzes und Prüfungen. + +## Standalone-Fragen +Einfache Single- und Multiple-Choice-Fragen: + +```md + + > Wir gilt als Erfinder des World Wide Web (WWW)? + + 1. Steve **Jobs** + 2. Tim Berners-Lee + 3. Ada __Lovelace__ + 4. Alain Berset :mdi[cheese] + 5. Charles Bartowski + +``` + + + + > Wir gilt als Erfinder des World Wide Web (WWW)? + + 1. Steve **Jobs** + 2. Tim Berners-Lee + 3. Ada __Lovelace__ + 4. Alain Berset :mdi[cheese] + 5. Charles Bartowski + + + +```md + + > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. + + :::info[Bewertung] + Die Frage wird nur als richtig bewertet, wenn alle korrekten Antworten (und keine falschen) ausgewählt wurden. + ::: + + 1. 2 ist die einzige gerade Primzahl. + 2. Jede Primzahl ist ungerade. + 3. 1 ist eine Primzahl. + 4. 5 ist eine Primzahl. + 5. Primzahlen sind nur durch 1 und sich selbst teilbar. + + **Gewusst?** **Prim**zahlen haben nichts mit **Prim**aten zu tun! + +``` + + + + > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. + + :::info[Bewertung] + Die Frage wird nur als richtig bewertet, wenn alle korrekten Antworten (und keine falschen) ausgewählt wurden. + ::: + + 1. 2 ist die einzige gerade Primzahl. + 2. Jede Primzahl ist ungerade. + 3. 1 ist eine Primzahl. + 4. 5 ist eine Primzahl. + 5. Primzahlen sind nur durch 1 und sich selbst teilbar. + + **Gewusst?** **Prim**zahlen haben nichts mit **Prim**aten zu tun! + + + +Für Wahr/Falsch-Fragen steht eine spezielle Komponente zur Verfügung: + +```md + + Die Erde ist flach. + +``` + +Mit der `randomize`-Property werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. Zudem dürfen bei SC-Aufgaben auch mehrere Optionen korrekt sein. Wird eine davon ausgewählt, gilt die Antwort als richtig: + +```md + + Welche der folgenden Programmiersprachen sind statisch typisiert? **Hinweis:** Es kann mehr als eine Antwort korrekt sein. + + 1. TypeScript + 2. Python + 3. JavaScript + 4. Java + 5. Ruby + +``` + +## Fragegruppen +Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Fragen zusammengefasst. Dies kann mit der ``-Komponente erreicht werden: + +```html + + + > In welchem Jahr war 2024? + + 1. 1965 + 2. 1983 + 3. 1991 + 4. 2000 + 5. 2024 + + + + > HTML ist eine Programmiersprache. + + + + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? + + 1. SMTP + 2. FTP + 3. IMAP + 4. HTTP + + +``` + +## Eigenschaften +### ChoiceAnswer +| Eigenschaft | Typ | Beschreibung | +|------------------|-------------------|---------------------------------------------------| +| `correct` | `number[]` | Array mit den IDs der Indizes der korrekten Antwortmöglichkeiten. | +| `multiple` | Flag | Wenn gesetzt, können mehrere Antworten ausgewählt werden (Multiple-Choice). Standard: Single-Choice. | +| `randomize` | Flag | Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. | + +### Quiz +| Eigenschaft | Typ | Beschreibung | +|------------------|-------------------|---------------------------------------------------| +| `randomize` | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge dargestellt. | +| `randomizeOptions` | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge dargestellt (analog zu `ChoiceAnswer.randomize` für einzelne Fragen). | +| `carrousel` | Flag | Wenn gesetzt, werden die Fragen in einem Karussell dargestellt, sodass immer nur eine Frage sichtbar ist. | +| `grading` | Object | **TODO.** Objekt zur Anpassung der Bewertungslogik. Siehe unten. | + +## TODO +- Bei MC-Aufgaben sind Teilpunktzahlen möglich. + - TODO TODO: Standalone MC-Fragen müssten demnach auch nicht nur mit richtig und falsch, sondern mit "teilweise richtig" bewertet werden? +- Ein Quiz kann auf ein Karussell reduziert werden, um Platz zu sparen. +- Bei einem Quiz kann eine Gesamtpunktzahl definiert werden, die auf die einzelnen ChoiceAnswers aufgeteilt wird. +- Die Wertung der Antworten kann angepasst werden – es sind z.B. auch Negativpunkte möglich. Es kann zudem eingestellt werden, ob die Gruppe insgesamt weniger als 0 Punkte ergeben darf. +- Der Korrekturknopf eines Quiz kann versteckt oder mit einer Permission geschützt werden. +- Bei einem Quiz kann eingestellt werden, ob die Fragen in der vorgegebenen Reihenfolge oder zufällig angezeigt werden sollen. +- TODO TODO: + - Korrektur: Einzeln oder nur Punkte? + - Konzept eines "Durchgangs" (Versuch)? Versuch verwerfen und neu starten nach Korrektur? + - Soll eine CA / CA.Group automatische eine Aufgabe sein? Soll sie automatisch eine Checkbox erhalten? Soll diese automatisch aktiviert sein, wenn das Quiz fertig gelöst ist? Soll sie nur aktiviert sein, wenn alles korrekt gelöst wurde? Soll das alles eine Option sein? + +## Future Work +- Statt die korrekten Antworten direkt in der Komponente zu markieren, soll bei einem Quiz auch angegeben werden können, dass die Lösung extern abgespeichert ist. In diesem Fall verfügt die Gruppe über ein Upload-Feld, über welches das entsprechende Lösungs-File hochgeladen werden kann. Die Lösungen werden nach erfolgreichem Upload im LocalStore gespeichert, damit z.B. bei Prüfungen mehrere Schüler:innen korrigiert werden können. Für Admins steht zudem ein Download-Button bereit, mit dem sie ein Template für die Lösungen einer spezifischen Gruppe herunterladen können. +- Allgemeine Überlegung: Im Sinne einer Autokorrektur für Prüfungen soll das `Quiz` eine Funktion anbieten, die ein Lösungsdokument (z.B. als Teil eines Lösungsdokuments für die gesamte Prüfung) entgegennimmt und als Antwort eine Punktzahl und z.B. einen Report in Form `4 richtig | 1 falsch | 0 nicht beantwortet` zurückgibt. Dies könnte dann als Korrektur für diese Aufgabe in Korrektur-Document des entsprechenden Schülers eingetragen werden (während z.B. bei Textaufgaben eine manuelle Feedback- und Punkteeingabe durch die Lehrperson erfolgt). \ No newline at end of file