VBV-306: Use GraphQL for assignment code

This commit is contained in:
Daniel Egger 2023-05-15 19:00:42 +02:00
parent e130d65f37
commit 31dae0a5cd
68 changed files with 1667 additions and 3369 deletions

View File

@ -12,7 +12,7 @@ trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.{html,css,scss,json,yml,js,vue,ts,md,xml}]
[*.{html,css,scss,json,yml,js,vue,ts,md,xml,gql}]
indent_style = space
indent_size = 2

View File

@ -209,3 +209,28 @@ There are some rules when it comes to the folder structure of the frontend.
   ├─ AbcButton.vue
   └─ AbcListTile.vue
```
## GraphQL
When you change something on the server side run the following command to update the
graphql schema:
```bash
python manage.py graphql_schema
```
On the client side you can (or even have to) run the following command to update the
generated code
```bash
npm run codegen
# or in watch mode
npm run codegen:watch
```
### Open Questions
- The `id` field has to be a string?
- Is running `codegen` a prerequisite so that it even works?
- The functions is `cacheExchange` should be nearer the concrete implementation

View File

@ -15,7 +15,14 @@ module.exports = {
env: {
"vue/setup-compiler-macros": true,
},
ignorePatterns: ["versionize.js", "tailwind.config.js", "postcss.config.js"],
ignorePatterns: [
"versionize.js",
"tailwind.config.js",
"postcss.config.js",
"src/gql/**/*.ts",
"src/stories/**/*",
"src/**/*.stories.ts",
],
rules: {
"@typescript-eslint/ban-ts-comment": "warn",
"@typescript-eslint/no-explicit-any": "warn",

View File

@ -2,3 +2,4 @@ dist
node_modules
**/__tests__/*.json
src/colors.json
src/gql/*

View File

@ -1,7 +1,8 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "../server/schema.graphql",
documents: ["src/**/*.vue"],
documents: ["src/**/*.vue", "src/graphql/**/*.ts"],
ignoreNoDocuments: true,
generates: {
"./src/gql/": {

112
client/package-lock.json generated
View File

@ -12,7 +12,9 @@
"@headlessui/vue": "1.7.7",
"@sentry/tracing": "^7.20.0",
"@sentry/vue": "^7.20.0",
"@urql/vue": "^1.0.2",
"@urql/devtools": "^2.0.3",
"@urql/exchange-graphcache": "^6.0.4",
"@urql/vue": "^1.1.1",
"@vueuse/core": "^9.13.0",
"@vueuse/router": "^10.1.2",
"cypress": "^12.9.0",
@ -89,6 +91,19 @@
"vue-tsc": "^1.0.9"
}
},
"node_modules/@0no-co/graphql.web": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.0.1.tgz",
"integrity": "sha512-6Yaxyv6rOwRkLIvFaL0NrLDgfNqC/Ng9QOPmTmlqW4mORXMEKmh5NYGkIvvt5Yw8fZesnMAqkj8cIqTj8f40cQ==",
"peerDependencies": {
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0"
},
"peerDependenciesMeta": {
"graphql": {
"optional": true
}
}
},
"node_modules/@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@ -6996,26 +7011,45 @@
}
},
"node_modules/@urql/core": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@urql/core/-/core-3.2.2.tgz",
"integrity": "sha512-i046Cz8cZ4xIzGMTyHZrbdgzcFMcKD7+yhCAH5FwWBRjcKrc+RjEOuR9X5AMuBvr8c6IAaE92xAqa4wmlGfWTQ==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@urql/core/-/core-4.0.7.tgz",
"integrity": "sha512-UtZ9oSbSFODXzFydgLCXpAQz26KGT1d6uEfcylKphiRWNXSWZi8k7vhJXNceNm/Dn0MiZ+kaaJHKcnGY1jvHRQ==",
"dependencies": {
"wonka": "^6.1.2"
"@0no-co/graphql.web": "^1.0.1",
"wonka": "^6.3.2"
}
},
"node_modules/@urql/devtools": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@urql/devtools/-/devtools-2.0.3.tgz",
"integrity": "sha512-TktPLiBS9LcBPHD6qcnb8wqOVcg3Bx0iCtvQ80uPpfofwwBGJmqnQTjUdEFU6kwaLOFZULQ9+Uo4831G823mQw==",
"dependencies": {
"wonka": ">= 4.0.9"
},
"peerDependencies": {
"graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
"@urql/core": ">= 1.14.0",
"graphql": ">= 0.11.0"
}
},
"node_modules/@urql/exchange-graphcache": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@urql/exchange-graphcache/-/exchange-graphcache-6.0.4.tgz",
"integrity": "sha512-fzfCUrHzhLycPRa6kYrQYryk0pwmUfeHY9Xqh11A6wtp4/diV7FASlffB5k2j3AWRBxBnqThXDssRXI/G8cACQ==",
"dependencies": {
"@0no-co/graphql.web": "^1.0.1",
"@urql/core": ">=4.0.0",
"wonka": "^6.3.2"
}
},
"node_modules/@urql/vue": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@urql/vue/-/vue-1.0.5.tgz",
"integrity": "sha512-RiXiINAr/tBJTIRcbA+TPCwRvK/z3NJy7+NOQvXHV/c9nCDrmUgu7HqdVMtAmlh5jUIw3HR8nI8IrRUoJqchHw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@urql/vue/-/vue-1.1.1.tgz",
"integrity": "sha512-dFHyJdqcTbr5P5nLfagh7CYDuuy91S7T0oaWZRJJDpq+53uTAjaRk7XQ8UNQH2oaZTO5djhQRruaPOY2JbjJsg==",
"dependencies": {
"@urql/core": "^3.2.0",
"wonka": "^6.0.0"
"@urql/core": "^4.0.0",
"wonka": "^6.3.2"
},
"peerDependencies": {
"graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
"vue": "^2.7.0 || ^3.0.0"
}
},
@ -19554,9 +19588,9 @@
}
},
"node_modules/wonka": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.1.tgz",
"integrity": "sha512-nJyGPcjuBiaLFn8QAlrHd+QjV9AlPO7snOWAhgx6aX0nQLMV6Wi0nqfrkmsXIH0efngbDOroOz2QyLnZMF16Hw=="
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.2.tgz",
"integrity": "sha512-2xXbQ1LnwNS7egVm1HPhW2FyKrekolzhpM3mCwXdQr55gO+tAiY76rhb32OL9kKsW8taj++iP7C6hxlVzbnvrw=="
},
"node_modules/word-wrap": {
"version": "1.2.3",
@ -19741,6 +19775,11 @@
}
},
"dependencies": {
"@0no-co/graphql.web": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.0.1.tgz",
"integrity": "sha512-6Yaxyv6rOwRkLIvFaL0NrLDgfNqC/Ng9QOPmTmlqW4mORXMEKmh5NYGkIvvt5Yw8fZesnMAqkj8cIqTj8f40cQ=="
},
"@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@ -24929,20 +24968,39 @@
}
},
"@urql/core": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@urql/core/-/core-3.2.2.tgz",
"integrity": "sha512-i046Cz8cZ4xIzGMTyHZrbdgzcFMcKD7+yhCAH5FwWBRjcKrc+RjEOuR9X5AMuBvr8c6IAaE92xAqa4wmlGfWTQ==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@urql/core/-/core-4.0.7.tgz",
"integrity": "sha512-UtZ9oSbSFODXzFydgLCXpAQz26KGT1d6uEfcylKphiRWNXSWZi8k7vhJXNceNm/Dn0MiZ+kaaJHKcnGY1jvHRQ==",
"requires": {
"wonka": "^6.1.2"
"@0no-co/graphql.web": "^1.0.1",
"wonka": "^6.3.2"
}
},
"@urql/devtools": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@urql/devtools/-/devtools-2.0.3.tgz",
"integrity": "sha512-TktPLiBS9LcBPHD6qcnb8wqOVcg3Bx0iCtvQ80uPpfofwwBGJmqnQTjUdEFU6kwaLOFZULQ9+Uo4831G823mQw==",
"requires": {
"wonka": ">= 4.0.9"
}
},
"@urql/exchange-graphcache": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@urql/exchange-graphcache/-/exchange-graphcache-6.0.4.tgz",
"integrity": "sha512-fzfCUrHzhLycPRa6kYrQYryk0pwmUfeHY9Xqh11A6wtp4/diV7FASlffB5k2j3AWRBxBnqThXDssRXI/G8cACQ==",
"requires": {
"@0no-co/graphql.web": "^1.0.1",
"@urql/core": ">=4.0.0",
"wonka": "^6.3.2"
}
},
"@urql/vue": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@urql/vue/-/vue-1.0.5.tgz",
"integrity": "sha512-RiXiINAr/tBJTIRcbA+TPCwRvK/z3NJy7+NOQvXHV/c9nCDrmUgu7HqdVMtAmlh5jUIw3HR8nI8IrRUoJqchHw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@urql/vue/-/vue-1.1.1.tgz",
"integrity": "sha512-dFHyJdqcTbr5P5nLfagh7CYDuuy91S7T0oaWZRJJDpq+53uTAjaRk7XQ8UNQH2oaZTO5djhQRruaPOY2JbjJsg==",
"requires": {
"@urql/core": "^3.2.0",
"wonka": "^6.0.0"
"@urql/core": "^4.0.0",
"wonka": "^6.3.2"
}
},
"@vitejs/plugin-vue": {
@ -34332,9 +34390,9 @@
}
},
"wonka": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.1.tgz",
"integrity": "sha512-nJyGPcjuBiaLFn8QAlrHd+QjV9AlPO7snOWAhgx6aX0nQLMV6Wi0nqfrkmsXIH0efngbDOroOz2QyLnZMF16Hw=="
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.2.tgz",
"integrity": "sha512-2xXbQ1LnwNS7egVm1HPhW2FyKrekolzhpM3mCwXdQr55gO+tAiY76rhb32OL9kKsW8taj++iP7C6hxlVzbnvrw=="
},
"word-wrap": {
"version": "1.2.3",

View File

@ -2,28 +2,31 @@
"name": "client",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build && node versionize && cp ./dist/index.html ../server/vbv_lernwelt/templates/vue/index.html && rm -rf ../server/vbv_lernwelt/static/vue/* && cp -r ./dist/static/vue ../server/vbv_lernwelt/static/ && npm run build-storybook",
"build-storybook": "rm -rf ../server/vbv_lernwelt/static/storybook/* && storybook build -o ../server/vbv_lernwelt/static/storybook",
"build:tailwind": "tailwindcss -i tailwind.css -o ../server/vbv_lernwelt/static/css/tailwind.css --minify",
"codegen": "graphql-codegen",
"test": "vitest run",
"codegen:watch": "graphql-codegen --watch",
"coverage": "vitest run --coverage",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"cypress:open": "cypress open",
"dev": "vite",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"vue-i18n-extract": "vue-i18n-extract report",
"prettier": "prettier . --write",
"prettier:check": "prettier . --check",
"tailwind": "tailwindcss -i tailwind.css -o ../server/vbv_lernwelt/static/css/tailwind.css --watch",
"storybook": "storybook dev -p 6006",
"build-storybook": "rm -rf ../server/vbv_lernwelt/static/storybook/* && storybook build -o ../server/vbv_lernwelt/static/storybook",
"cypress:open": "cypress open"
"tailwind": "tailwindcss -i tailwind.css -o ../server/vbv_lernwelt/static/css/tailwind.css --watch",
"test": "vitest run",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"vue-i18n-extract": "vue-i18n-extract report"
},
"dependencies": {
"@headlessui/tailwindcss": "^0.1.2",
"@headlessui/vue": "1.7.7",
"@sentry/tracing": "^7.20.0",
"@sentry/vue": "^7.20.0",
"@urql/vue": "^1.0.2",
"@urql/devtools": "^2.0.3",
"@urql/exchange-graphcache": "^6.0.4",
"@urql/vue": "^1.1.1",
"@vueuse/core": "^9.13.0",
"@vueuse/router": "^10.1.2",
"cypress": "^12.9.0",

View File

@ -15,13 +15,17 @@ import log from "loglevel";
import AppFooter from "@/components/AppFooter.vue";
import MainNavigationBar from "@/components/header/MainNavigationBar.vue";
import { graphqlClient } from "@/graphql/client";
import eventBus from "@/utils/eventBus";
import { provideClient } from "@urql/vue";
import { onMounted, ref } from "vue";
const componentKey = ref(1);
log.debug("App created");
provideClient(graphqlClient);
onMounted(() => {
log.debug("App mounted");

View File

@ -53,8 +53,8 @@ const numSteps = stepLabels.length;
const sendFeedbackMutation = graphql(`
mutation SendFeedbackMutation($input: SendFeedbackInput!) {
sendFeedback(input: $input) {
feedbackResponse {
send_feedback(input: $input) {
feedback_response {
id
}
errors {
@ -112,7 +112,7 @@ const sendFeedback = () => {
would_recommend: wouldRecommend,
},
page: props.page.translation_key,
courseSession: courseSession.id,
course_session: courseSession.id,
});
const variables = reactive({
input,

View File

@ -1,13 +1,16 @@
import type { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core";
import type { ResultOf, TypedDocumentNode as DocumentNode, } from '@graphql-typed-document-node/core';
export type FragmentType<TDocumentType extends DocumentNode<any, any>> =
TDocumentType extends DocumentNode<infer TType, any>
? TType extends { " $fragmentName"?: infer TKey }
? TKey extends string
? { " $fragmentRefs"?: { [key in TKey]: TType } }
: never
export type FragmentType<TDocumentType extends DocumentNode<any, any>> = TDocumentType extends DocumentNode<
infer TType,
any
>
? TType extends { ' $fragmentName'?: infer TKey }
? TKey extends string
? { ' $fragmentRefs'?: { [key in TKey]: TType } }
: never
: never;
: never
: never;
// return non-nullable if `fragmentType` is non-nullable
export function useFragment<TType>(
@ -31,11 +34,15 @@ export function useFragment<TType>(
): ReadonlyArray<TType> | null | undefined;
export function useFragment<TType>(
_documentNode: DocumentNode<TType, any>,
fragmentType:
| FragmentType<DocumentNode<TType, any>>
| ReadonlyArray<FragmentType<DocumentNode<TType, any>>>
| null
| undefined
fragmentType: FragmentType<DocumentNode<TType, any>> | ReadonlyArray<FragmentType<DocumentNode<TType, any>>> | null | undefined
): TType | ReadonlyArray<TType> | null | undefined {
return fragmentType as any;
}
export function makeFragmentData<
F extends DocumentNode,
FT extends ResultOf<F>
>(data: FT, _fragment: F): FragmentType<F> {
return data as FragmentType<F>;
}

View File

@ -1,20 +1,57 @@
/* eslint-disable */
import type { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core";
import * as types from "./graphql";
import * as types from './graphql';
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
/**
* Map of all GraphQL operations in the project.
*
* This map has several performance disadvantages:
* 1. It is not tree-shakeable, so it will include all operations in the project.
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
* 3. It does not support dead code elimination, so it will add unused operations.
*
* Therefore it is highly recommended to use the babel-plugin for production.
*/
const documents = {
"\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n feedbackResponse {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n":
types.SendFeedbackMutationDocument,
"\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n send_feedback(input: $input) {\n feedback_response {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n": types.SendFeedbackMutationDocument,
"\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $assignmentUserId: ID\n $completionStatus: String!\n $completionDataString: String!\n $evaluationGrade: Float\n $evaluationPoints: Float\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_grade: $evaluationGrade\n evaluation_points: $evaluationPoints\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_grade\n evaluation_points\n completion_data\n }\n }\n }\n": types.UpsertAssignmentCompletionDocument,
"\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $assignmentUserId: ID\n ) {\n assignment(id: $assignmentId) {\n id\n content_type\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n performance_objectives\n starting_position\n tasks\n title\n translation_key\n slug\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n }\n assignment_user {\n id\n }\n evaluation_grade\n evaluation_points\n completion_data\n }\n }\n": types.AssignmentCompletionQueryDocument,
"\n query courseQuery($courseId: Int!) {\n course(id: $courseId) {\n id\n slug\n title\n category_name\n learning_path {\n id\n }\n }\n }\n": types.CourseQueryDocument,
};
export function graphql(
source: "\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n feedbackResponse {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n"
): (typeof documents)["\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n feedbackResponse {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*
*
* @example
* ```ts
* const query = gql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
* ```
*
* The query argument is unknown!
* Please regenerate the types.
*/
export function graphql(source: string): unknown;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n send_feedback(input: $input) {\n feedback_response {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n"): (typeof documents)["\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n send_feedback(input: $input) {\n feedback_response {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $assignmentUserId: ID\n $completionStatus: String!\n $completionDataString: String!\n $evaluationGrade: Float\n $evaluationPoints: Float\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_grade: $evaluationGrade\n evaluation_points: $evaluationPoints\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_grade\n evaluation_points\n completion_data\n }\n }\n }\n"): (typeof documents)["\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $assignmentUserId: ID\n $completionStatus: String!\n $completionDataString: String!\n $evaluationGrade: Float\n $evaluationPoints: Float\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_grade: $evaluationGrade\n evaluation_points: $evaluationPoints\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_grade\n evaluation_points\n completion_data\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $assignmentUserId: ID\n ) {\n assignment(id: $assignmentId) {\n id\n content_type\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n performance_objectives\n starting_position\n tasks\n title\n translation_key\n slug\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n }\n assignment_user {\n id\n }\n evaluation_grade\n evaluation_points\n completion_data\n }\n }\n"): (typeof documents)["\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $assignmentUserId: ID\n ) {\n assignment(id: $assignmentId) {\n id\n content_type\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n performance_objectives\n starting_position\n tasks\n title\n translation_key\n slug\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n }\n assignment_user {\n id\n }\n evaluation_grade\n evaluation_points\n completion_data\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query courseQuery($courseId: Int!) {\n course(id: $courseId) {\n id\n slug\n title\n category_name\n learning_path {\n id\n }\n }\n }\n"): (typeof documents)["\n query courseQuery($courseId: Int!) {\n course(id: $courseId) {\n id\n slug\n title\n category_name\n learning_path {\n id\n }\n }\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
}
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> =
TDocumentNode extends DocumentNode<infer TType, any> ? TType : never;
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
import { devtoolsExchange } from "@urql/devtools";
import { cacheExchange } from "@urql/exchange-graphcache";
import { Client, fetchExchange } from "@urql/vue";
export const graphqlClient = new Client({
url: import.meta.env.VITE_GRAPHQL_URL || "/server/graphql/",
exchanges: [
devtoolsExchange,
cacheExchange({
updates: {
Mutation: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
upsert_assignment_completion(result, args, cache, info) {
// will run on every mutation
// console.log("udpates upsert_assignment_completion", result);
},
},
},
optimistic: {
// @ts-ignore, weiss oh nöt...
upsert_assignment_completion(args, cache, info) {
console.log("optimistic upsert_assignment_completion", args, cache, info);
const id = info.variables.id;
if (id) {
const completionData = cache.resolve(
{ __typename: "AssignmentCompletionType", id: "1" },
"completion_data"
);
return {
__typename: "AssignmentCompletionMutation",
assignment_completion: {
__typename: "AssignmentCompletionType",
id: id,
completion_data: Object.assign(
{},
completionData,
// @ts-ignore, weiss oh nöt...
JSON.parse(args.completion_data_string || "{}")
),
completion_status: args.completion_status,
evaluation_grade: args.evaluation_grade,
evaluation_points: args.evaluation_points,
},
};
}
},
},
}),
fetchExchange,
],
});

View File

@ -0,0 +1,33 @@
import { graphql } from "@/gql";
export const UPSERT_ASSIGNMENT_COMPLETION_MUTATION = graphql(`
mutation UpsertAssignmentCompletion(
$assignmentId: ID!
$courseSessionId: ID!
$assignmentUserId: ID
$completionStatus: String!
$completionDataString: String!
$evaluationGrade: Float
$evaluationPoints: Float
) {
upsert_assignment_completion(
assignment_id: $assignmentId
course_session_id: $courseSessionId
assignment_user_id: $assignmentUserId
completion_status: $completionStatus
completion_data_string: $completionDataString
evaluation_grade: $evaluationGrade
evaluation_points: $evaluationPoints
) {
assignment_completion {
id
completion_status
submitted_at
evaluation_submitted_at
evaluation_grade
evaluation_points
completion_data
}
}
}
`);

View File

@ -0,0 +1,56 @@
import { graphql } from "@/gql";
export const ASSIGNMENT_COMPLETION_QUERY = graphql(`
query assignmentCompletionQuery(
$assignmentId: ID!
$courseSessionId: ID!
$assignmentUserId: ID
) {
assignment(id: $assignmentId) {
id
content_type
evaluation_description
evaluation_document_url
evaluation_tasks
performance_objectives
starting_position
tasks
title
translation_key
slug
}
assignment_completion(
assignment_id: $assignmentId
course_session_id: $courseSessionId
assignment_user_id: $assignmentUserId
) {
id
completion_status
submitted_at
evaluation_submitted_at
evaluation_user {
id
}
assignment_user {
id
}
evaluation_grade
evaluation_points
completion_data
}
}
`);
export const COURSE_QUERY = graphql(`
query courseQuery($courseId: Int!) {
course(id: $courseId) {
id
slug
title
category_name
learning_path {
id
}
}
}
`);

View File

@ -2,16 +2,12 @@ import * as Sentry from "@sentry/vue";
import * as log from "loglevel";
import { createPinia } from "pinia";
import { createApp, markRaw } from "vue";
import urql from "@urql/vue";
import type { Router } from "vue-router";
import "../tailwind.css";
import App from "./App.vue";
import { loadLocaleMessages, setupI18n } from "./i18n";
import router from "./router";
import type { Router } from "vue-router";
import "../tailwind.css";
declare module "pinia" {
export interface PiniaCustomProperties {
router: Router;
@ -50,9 +46,6 @@ loadLocaleMessages("de").then(() => {
});
app.use(pinia);
app.use(i18n);
app.use(urql, {
url: import.meta.env.VITE_GRAPHQL_URL || "/server/graphql/",
});
app.mount("#app");
});

View File

@ -1,13 +1,15 @@
<script setup lang="ts">
import { ASSIGNMENT_COMPLETION_QUERY } from "@/graphql/queries";
import EvaluationContainer from "@/pages/cockpit/assignmentEvaluationPage/EvaluationContainer.vue";
import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type {
Assignment,
AssignmentCompletion,
CourseSessionAssignmentDetails,
CourseSessionUser,
} from "@/types";
import { useQuery } from "@urql/vue";
import log from "loglevel";
import { computed, onMounted, reactive } from "vue";
import { useRouter } from "vue-router";
@ -21,21 +23,28 @@ const props = defineProps<{
log.debug("AssignmentEvaluationPage created", props.assignmentId, props.userId);
interface StateInterface {
assignment: Assignment | undefined;
courseSessionAssignmentDetails: CourseSessionAssignmentDetails | undefined;
assignmentUser: CourseSessionUser | undefined;
}
const state: StateInterface = reactive({
assignment: undefined,
courseSessionAssignmentDetails: undefined,
assignmentUser: undefined,
});
const assignmentStore = useAssignmentStore();
const courseSessionsStore = useCourseSessionsStore();
const router = useRouter();
// noinspection TypeScriptValidateTypes TODO: because of IntelliJ
const queryResult = useQuery({
query: ASSIGNMENT_COMPLETION_QUERY,
variables: {
courseSessionId: courseSessionsStore.currentCourseSession!.id.toString(),
assignmentId: props.assignmentId,
assignmentUserId: props.userId,
},
});
onMounted(async () => {
log.debug("AssignmentView mounted", props.assignmentId, props.userId);
@ -44,17 +53,6 @@ onMounted(async () => {
(user) => user.user_id === Number(props.userId)
);
}
try {
state.assignment = await assignmentStore.loadAssignment(Number(props.assignmentId));
await assignmentStore.loadAssignmentCompletion(
Number(props.assignmentId),
courseSessionsStore.currentCourseSession?.id ?? 0,
props.userId
);
} catch (error) {
log.error(error);
}
});
function close() {
@ -63,13 +61,20 @@ function close() {
});
}
const assignmentCompletion = computed(() => assignmentStore.assignmentCompletion);
const assignmentCompletion = computed(
() =>
queryResult.data.value?.assignment_completion as AssignmentCompletion | undefined
);
const assignment = computed(
() => queryResult.data.value?.assignment as Assignment | undefined
);
</script>
<template>
<div class="absolute bottom-0 top-0 z-10 w-full bg-white">
<div
v-if="state.assignment && state.assignmentUser && assignmentCompletion"
v-if="assignment && assignmentCompletion && state.assignmentUser"
class="relative"
>
<header
@ -77,7 +82,7 @@ const assignmentCompletion = computed(() => assignmentStore.assignmentCompletion
>
<div class="flex items-center text-gray-900">
<it-icon-assignment class="h-6 w-6"></it-icon-assignment>
<div class="ml-2">Geleitete Fallarbeit: {{ state.assignment.title }}</div>
<div class="ml-2">Geleitete Fallarbeit: {{ assignment.title }}</div>
</div>
<button
type="button"
@ -94,6 +99,7 @@ const assignmentCompletion = computed(() => assignmentStore.assignmentCompletion
<!-- Left part content goes here -->
<div class="p-10" data-cy="student-submission">
<h3>Ergebnisse</h3>
<div class="my-6 flex items-center">
<img
:src="state.assignmentUser?.avatar_url"
@ -105,7 +111,7 @@ const assignmentCompletion = computed(() => assignmentStore.assignmentCompletion
</div>
</div>
<AssignmentSubmissionResponses
:assignment="state.assignment"
:assignment="assignment"
:assignment-completion-data="assignmentCompletion.completion_data"
:allow-edit="false"
></AssignmentSubmissionResponses>
@ -115,7 +121,7 @@ const assignmentCompletion = computed(() => assignmentStore.assignmentCompletion
<EvaluationContainer
:assignment-completion="assignmentCompletion"
:assignment-user="state.assignmentUser"
:assignment="state.assignment"
:assignment="assignment"
@close="close()"
></EvaluationContainer>
</div>

View File

@ -2,7 +2,7 @@
import EvaluationIntro from "@/pages/cockpit/assignmentEvaluationPage/EvaluationIntro.vue";
import EvaluationSummary from "@/pages/cockpit/assignmentEvaluationPage/EvaluationSummary.vue";
import EvaluationTask from "@/pages/cockpit/assignmentEvaluationPage/EvaluationTask.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { findAssignmentDetail } from "@/services/assignmentService";
import type {
Assignment,
AssignmentCompletion,
@ -28,11 +28,9 @@ log.debug("UserEvaluation setup");
// 0 = introduction, 1 - n = tasks, n+1 = submission
const stepIndex = useRouteQuery("step", "0", { transform: Number, mode: "push" });
const assignmentStore = useAssignmentStore();
const numTasks = computed(() => props.assignment.evaluation_tasks?.length ?? 0);
const evaluationSubmitted = computed(
() => props.assignmentCompletion.completion_status === "evaluation_submitted"
() => props.assignmentCompletion.completion_status === "EVALUATION_SUBMITTED"
);
onMounted(() => {
@ -60,9 +58,7 @@ function editTask(task: AssignmentEvaluationTask) {
stepIndex.value = taskIndex + 1;
}
const assignmentDetail = computed(() =>
assignmentStore.findAssignmentDetail(props.assignment.id)
);
const assignmentDetail = computed(() => findAssignmentDetail(props.assignment.id));
const dueDate = computed(() =>
dayjs(assignmentDetail.value?.evaluationDeadlineDateTimeUtc)
@ -78,8 +74,8 @@ const taskExpertDataText = computed(() => {
let result = "";
if (inEvaluationTask.value) {
result =
assignmentStore.assignmentCompletion?.completion_data?.[task.value.id]
?.expert_data?.text ?? "";
props.assignmentCompletion?.completion_data?.[task.value.id]?.expert_data?.text ??
"";
}
return result;
});
@ -92,7 +88,7 @@ function nextButtonEnabled() {
}
function finishButtonEnabled() {
return props.assignmentCompletion.completion_status === "evaluation_submitted";
return props.assignmentCompletion.completion_status === "EVALUATION_SUBMITTED";
}
</script>
@ -112,9 +108,10 @@ function finishButtonEnabled() {
v-else-if="inEvaluationTask"
:assignment-user="props.assignmentUser"
:assignment="props.assignment"
:assignment-completion="props.assignmentCompletion"
:task-index="stepIndex - 1"
:allow-edit="
props.assignmentCompletion.completion_status !== 'evaluation_submitted'
props.assignmentCompletion.completion_status !== 'EVALUATION_SUBMITTED'
"
/>
<EvaluationSummary

View File

@ -1,7 +1,8 @@
<script setup lang="ts">
import { useAssignmentStore } from "@/stores/assignmentStore";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { Assignment, AssignmentCompletion, CourseSessionUser } from "@/types";
import { useMutation } from "@urql/vue";
import dayjs, { Dayjs } from "dayjs";
import * as log from "loglevel";
@ -17,18 +18,26 @@ const emit = defineEmits(["startEvaluation"]);
log.debug("EvaluationIntro setup");
const courseSessionsStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
async function startEvaluation() {
log.debug("startEvaluation");
if (props.assignmentCompletion.completion_status !== "evaluation_submitted") {
await assignmentStore.evaluateAssignmentCompletion({
assignment_user_id: Number(props.assignmentUser.user_id),
assignment_id: props.assignment.id,
course_session_id: courseSessionsStore.currentCourseSession!.id,
completion_data: {},
completion_status: "evaluation_in_progress",
if (props.assignmentCompletion.completion_status !== "EVALUATION_SUBMITTED") {
// noinspection TypeScriptValidateTypes
upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id.toString(),
courseSessionId: (courseSessionsStore?.currentCourseSession?.id ?? 0).toString(),
assignmentUserId: props.assignmentUser.user_id.toString(),
completionStatus: "EVALUATION_IN_PROGRESS",
completionDataString: JSON.stringify({}),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
id: props.assignmentCompletion?.id,
});
emit("startEvaluation");
} else {
emit("startEvaluation");
@ -68,14 +77,14 @@ async function startEvaluation() {
<button class="btn-primary" data-cy="start-evaluation" @click="startEvaluation()">
<span
v-if="
props.assignmentCompletion.completion_status === 'evaluation_in_progress'
props.assignmentCompletion.completion_status === 'EVALUATION_IN_PROGRESS'
"
>
Bewertung fortsetzen
</span>
<span
v-else-if="
props.assignmentCompletion.completion_status === 'evaluation_submitted'
props.assignmentCompletion.completion_status === 'EVALUATION_SUBMITTED'
"
>
Bewertung ansehen

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import {
maxAssignmentPoints,
pointsToGrade,
userAssignmentPoints,
} from "@/services/assignmentService";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type {
Assignment,
@ -13,6 +13,7 @@ import type {
AssignmentEvaluationTask,
CourseSessionUser,
} from "@/types";
import { useMutation } from "@urql/vue";
import dayjs, { Dayjs } from "dayjs";
import * as log from "loglevel";
import { computed, reactive } from "vue";
@ -34,24 +35,31 @@ const state = reactive({
log.debug("EvaluationSummary setup");
const courseSessionsStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
async function submitEvaluation() {
log.debug("submitEvaluation");
await assignmentStore.evaluateAssignmentCompletion({
assignment_user_id: Number(props.assignmentUser.user_id),
assignment_id: props.assignment.id,
course_session_id: courseSessionsStore?.currentCourseSession?.id ?? 0,
completion_data: {},
completion_status: "evaluation_submitted",
evaluation_grade: grade.value ?? undefined,
evaluation_points: userPoints.value,
// noinspection TypeScriptValidateTypes
upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id.toString(),
courseSessionId: (courseSessionsStore?.currentCourseSession?.id ?? 0).toString(),
assignmentUserId: props.assignmentUser.user_id.toString(),
completionStatus: "EVALUATION_SUBMITTED",
completionDataString: JSON.stringify({}),
evaluationGrade: grade.value ?? 1,
evaluationPoints: userPoints.value,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
id: props.assignmentCompletion?.id,
});
log.debug("submitEvaluation");
state.showSuccessInfo = true;
}
function subTaskByPoints(task: AssignmentEvaluationTask, points = 0) {
return task.value.sub_tasks.find((subTask) => subTask.points === points);
return task.value.sub_tasks.find((subTask) => subTask.value.points === points);
}
function evaluationForTask(task: AssignmentEvaluationTask) {
@ -71,7 +79,7 @@ const userPoints = computed(() =>
userAssignmentPoints(props.assignment, props.assignmentCompletion)
);
const grade = computed(() => {
if (props.assignmentCompletion.completion_status === "evaluation_submitted") {
if (props.assignmentCompletion.completion_status === "EVALUATION_SUBMITTED") {
return props.assignmentCompletion.evaluation_grade;
}
return pointsToGrade(userPoints.value, maxPoints.value);
@ -118,7 +126,7 @@ const evaluationUser = computed(() => {
</p>
<div
v-if="props.assignmentCompletion.completion_status === 'evaluation_submitted'"
v-if="props.assignmentCompletion.completion_status === 'EVALUATION_SUBMITTED'"
>
Freigabetermin:
{{
@ -150,7 +158,7 @@ const evaluationUser = computed(() => {
</div>
<div
v-if="
props.assignmentCompletion.completion_status !== 'evaluation_submitted'
props.assignmentCompletion.completion_status !== 'EVALUATION_SUBMITTED'
"
>
<button
@ -169,12 +177,14 @@ const evaluationUser = computed(() => {
<section class="mb-4">
<div
v-html="subTaskByPoints(task, evaluationForTask(task).points)?.title"
v-html="
subTaskByPoints(task, evaluationForTask(task).points)?.value.title
"
></div>
<p
class="default-wagtail-rich-text"
v-html="
subTaskByPoints(task, evaluationForTask(task).points)?.description
subTaskByPoints(task, evaluationForTask(task).points)?.value.description
"
></p>
<div class="text-sm text-gray-800">

View File

@ -1,13 +1,15 @@
<script setup lang="ts">
import ItTextarea from "@/components/ui/ItTextarea.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type {
Assignment,
AssignmentCompletion,
AssignmentCompletionData,
CourseSessionUser,
ExpertData,
} from "@/types";
import { useMutation } from "@urql/vue";
import { useDebounceFn } from "@vueuse/core";
import * as log from "loglevel";
import { computed } from "vue";
@ -15,6 +17,7 @@ import { computed } from "vue";
const props = defineProps<{
assignmentUser: CourseSessionUser;
assignment: Assignment;
assignmentCompletion: AssignmentCompletion;
taskIndex: number;
allowEdit: boolean;
}>();
@ -22,12 +25,11 @@ const props = defineProps<{
log.debug("EvaluationTask setup", props.taskIndex);
const courseSessionsStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
const task = computed(() => props.assignment.evaluation_tasks[props.taskIndex]);
const expertData = computed(() => {
const data = (assignmentStore.assignmentCompletion?.completion_data?.[task.value.id]
const data = (props.assignmentCompletion?.completion_data?.[task.value.id]
?.expert_data ?? {
points: 0,
text: "",
@ -56,15 +58,23 @@ function onUpdateText(value: string) {
});
}
const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
async function evaluateAssignmentCompletion(completionData: AssignmentCompletionData) {
log.debug("evaluateAssignmentCompletion", completionData);
return assignmentStore.evaluateAssignmentCompletion({
assignment_user_id: Number(props.assignmentUser.user_id),
assignment_id: props.assignment.id,
course_session_id: courseSessionsStore?.currentCourseSession?.id ?? 0,
completion_data: completionData,
completion_status: "evaluation_in_progress",
// noinspection TypeScriptValidateTypes
upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id.toString(),
courseSessionId: (courseSessionsStore?.currentCourseSession?.id ?? 0).toString(),
assignmentUserId: props.assignmentUser.user_id.toString(),
completionStatus: "EVALUATION_IN_PROGRESS",
completionDataString: JSON.stringify(completionData),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
id: props.assignmentCompletion?.id,
});
}
@ -91,26 +101,26 @@ const evaluateAssignmentCompletionDebounced = useDebounceFn(
v-for="(subTask, index) in task.value.sub_tasks"
:key="index"
class="mb-4 flex items-center last:mb-0"
:data-cy="`subtask-${subTask.points}`"
:data-cy="`subtask-${subTask.value.points}`"
>
<input
:id="String(index)"
name="coursesessions"
type="radio"
:value="subTask.points"
:checked="expertData.points === subTask.points"
:value="subTask.value.points"
:checked="expertData.points === subTask.value.points"
:disabled="!props.allowEdit"
class="focus:ring-indigo-900 h-4 w-4 border-gray-300 text-blue-900"
@change="changePoints(subTask.points)"
@change="changePoints(subTask.value.points)"
/>
<label :for="String(index)" class="ml-4 block">
<div>{{ subTask.title }}</div>
<div>{{ subTask.value.title }}</div>
<div
v-if="subTask.description"
v-if="subTask.value.description"
class="default-wagtail-rich-text"
v-html="subTask.description"
v-html="subTask.value.description"
></div>
<div class="text-sm text-gray-800">{{ subTask.points }} Punkte</div>
<div class="text-sm text-gray-800">{{ subTask.value.points }} Punkte</div>
</label>
</div>
</div>

View File

@ -3,8 +3,10 @@ import ItPersonRow from "@/components/ui/ItPersonRow.vue";
import type { StatusCount, StatusCountKey } from "@/components/ui/ItProgress.vue";
import AssignmentSubmissionProgress from "@/pages/cockpit/assignmentsPage/AssignmentSubmissionProgress.vue";
import type { AssignmentLearningContent } from "@/services/assignmentService";
import { loadAssignmentCompletionStatusData } from "@/services/assignmentService";
import { useAssignmentStore } from "@/stores/assignmentStore";
import {
findAssignmentDetail,
loadAssignmentCompletionStatusData,
} from "@/services/assignmentService";
import { useCockpitStore } from "@/stores/cockpit";
import type { AssignmentCompletionStatus, CourseSession } from "@/types";
import dayjs from "dayjs";
@ -19,7 +21,6 @@ const props = defineProps<{
log.debug("AssignmentDetails created", props.assignment.assignmentId);
const cockpitStore = useCockpitStore();
const assignmentStore = useAssignmentStore();
const state = reactive({
statusByUser: [] as {
@ -43,7 +44,7 @@ function submissionStatusForUser(userId: number) {
}
const assignmentDetail = computed(() =>
assignmentStore.findAssignmentDetail(props.assignment.assignmentId)
findAssignmentDetail(props.assignment.assignmentId)
);
</script>
@ -91,7 +92,7 @@ const assignmentDetail = computed(() =>
<section class="flex w-full justify-between px-8">
<div
v-if="
['evaluation_submitted'].includes(
['EVALUATION_SUBMITTED'].includes(
submissionStatusForUser(csu.user_id)?.userStatus ?? ''
)
"
@ -106,7 +107,7 @@ const assignmentDetail = computed(() =>
</div>
<div
v-else-if="
['evaluation_in_progress', 'submitted'].includes(
['EVALUATION_IN_PROGRESS', 'SUBMITTED'].includes(
submissionStatusForUser(csu.user_id)?.userStatus ?? ''
)
"

View File

@ -2,10 +2,11 @@
import ItButton from "@/components/ui/ItButton.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { Assignment, AssignmentCompletionData, AssignmentTask } from "@/types";
import type { Assignment, AssignmentCompletion, AssignmentTask } from "@/types";
import { useMutation } from "@urql/vue";
import type { Dayjs } from "dayjs";
import log from "loglevel";
import { computed, reactive } from "vue";
@ -13,7 +14,7 @@ import { useI18n } from "vue-i18n";
const props = defineProps<{
assignment: Assignment;
assignmentCompletionData: AssignmentCompletionData;
assignmentCompletion?: AssignmentCompletion;
courseSessionId: number;
dueDate: Dayjs;
}>();
@ -22,7 +23,6 @@ const emit = defineEmits<{
(e: "editTask", task: AssignmentTask): void;
}>();
const assignmentStore = useAssignmentStore();
const courseSessionsStore = useCourseSessionsStore();
const { t } = useI18n();
@ -40,9 +40,17 @@ const circleExpertName = computed(() => {
});
const completionStatus = computed(() => {
return assignmentStore.assignmentCompletion?.completion_status ?? "in_progress";
return props.assignmentCompletion?.completion_status ?? "IN_PROGRESS";
});
const completionData = computed(() => {
return props.assignmentCompletion?.completion_data ?? {};
});
const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
const onEditTask = (task: AssignmentTask) => {
emit("editTask", task);
};
@ -54,11 +62,16 @@ const onSubmit = async () => {
log.error("Invalid courseSessionId");
return;
}
await assignmentStore.upsertAssignmentCompletion({
assignment_id: props.assignment.id,
course_session_id: courseSessionId,
completion_data: {},
completion_status: "submitted",
// noinspection TypeScriptValidateTypes
upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id.toString(),
courseSessionId: courseSessionId.toString(),
completionDataString: JSON.stringify({}),
completionStatus: "SUBMITTED",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
id: props.assignmentCompletion?.id,
});
} catch (error) {
log.error("Could not submit assignment", error);
@ -71,7 +84,7 @@ const onSubmit = async () => {
{{ $t("assignment.acceptConditionsDisclaimer") }}
</h3>
<div v-if="completionStatus === 'in_progress'">
<div v-if="completionStatus === 'IN_PROGRESS'">
<ItCheckbox
class="w-full border-b border-gray-400 py-10 sm:py-6"
:checkbox-item="{
@ -133,8 +146,8 @@ const onSubmit = async () => {
</div>
<AssignmentSubmissionResponses
:assignment="props.assignment"
:assignment-completion-data="props.assignmentCompletionData"
:allow-edit="completionStatus === 'in_progress'"
:assignment-completion-data="completionData"
:allow-edit="completionStatus === 'IN_PROGRESS'"
@edit-task="onEditTask"
></AssignmentSubmissionResponses>
</template>

View File

@ -1,30 +1,33 @@
<script setup lang="ts">
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItTextarea from "@/components/ui/ItTextarea.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import type {
AssignmentCompletion,
AssignmentCompletionData,
AssignmentTask,
UserDataConfirmation,
UserDataText,
} from "@/types";
import { useMutation } from "@urql/vue";
import { useDebounceFn } from "@vueuse/core";
import dayjs from "dayjs";
import { computed, reactive, ref } from "vue";
import log from "loglevel";
import { computed, reactive } from "vue";
const props = defineProps<{
assignmentId: number;
task: AssignmentTask;
assignmentCompletion?: AssignmentCompletion;
}>();
const lastSaved = ref(dayjs());
const lastSaveUnsuccessful = ref(false);
const checkboxState = reactive({} as Record<string, boolean>);
const courseSessionsStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
async function upsertAssignmentCompletion(completion_data: AssignmentCompletionData) {
try {
@ -33,18 +36,20 @@ async function upsertAssignmentCompletion(completion_data: AssignmentCompletionD
console.error("Invalid courseSessionId");
return;
}
await assignmentStore.upsertAssignmentCompletion({
assignment_id: props.assignmentId,
course_session_id: courseSessionId,
completion_data: completion_data,
completion_status: "in_progress",
// noinspection TypeScriptValidateTypes
await upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignmentId.toString(),
courseSessionId: courseSessionId.toString(),
completionDataString: JSON.stringify(completion_data),
completionStatus: "IN_PROGRESS",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
id: props.assignmentCompletion?.id,
});
lastSaved.value = dayjs();
lastSaveUnsuccessful.value = false;
console.debug("Saved user input");
log.debug("Saved user input");
} catch (error) {
lastSaveUnsuccessful.value = true;
console.error("Could not save user input", error);
log.error("Could not save user input", error);
}
}
@ -53,6 +58,10 @@ const upsertAssignmentCompletionDebounced = useDebounceFn(
500
);
function getCompletionDataForUserInput(id: string) {
return props.assignmentCompletion?.completion_data[id];
}
const onUpdateText = (id: string, value: string) => {
const data: AssignmentCompletionData = {};
data[id] = {
@ -74,7 +83,7 @@ const onUpdateConfirmation = (id: string, value: boolean) => {
};
const getBlockData = (id: string) => {
const userData = assignmentStore.getCompletionDataForUserInput(id)?.user_data;
const userData = getCompletionDataForUserInput(id)?.user_data;
if (userData && "text" in userData) {
return userData.text;
} else if (userData && "confirmation" in userData) {
@ -89,7 +98,7 @@ const onToggleCheckbox = (id: string) => {
};
const completionStatus = computed(() => {
return assignmentStore.assignmentCompletion?.completion_status ?? "in_progress";
return props.assignmentCompletion?.completion_status ?? "IN_PROGRESS";
});
</script>
@ -108,7 +117,7 @@ const completionStatus = computed(() => {
value: `confirmation-${index}`,
checked: getBlockData(block.id) as boolean,
}"
:disabled="completionStatus !== 'in_progress'"
:disabled="completionStatus !== 'IN_PROGRESS'"
@toggle="onToggleCheckbox(block.id)"
></ItCheckbox>
</div>
@ -122,16 +131,12 @@ const completionStatus = computed(() => {
class="w-[300px] sm:w-[600px]"
:model-value="(getBlockData(block.id) as string) ?? ''"
:cy-key="`user-text-input-${index}`"
:disabled="completionStatus !== 'in_progress'"
:disabled="completionStatus !== 'IN_PROGRESS'"
label=""
@update:model-value="onUpdateText(block.id, $event)"
></ItTextarea>
</div>
</div>
<div v-if="lastSaveUnsuccessful" class="text-red-600">
{{ $t("assignment.lastChangesNotSaved") }}
</div>
</div>
<div v-if="props.task.value.file_submission_required">

View File

@ -1,19 +1,22 @@
<script setup lang="ts">
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import { ASSIGNMENT_COMPLETION_QUERY } from "@/graphql/queries";
import EvaluationSummary from "@/pages/cockpit/assignmentEvaluationPage/EvaluationSummary.vue";
import AssignmentIntroductionView from "@/pages/learningPath/learningContentPage/assignment/AssignmentIntroductionView.vue";
import AssignmentSubmissionView from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionView.vue";
import AssignmentTaskView from "@/pages/learningPath/learningContentPage/assignment/AssignmentTaskView.vue";
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import { useAssignmentStore } from "@/stores/assignmentStore";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useUserStore } from "@/stores/user";
import type {
Assignment,
AssignmentCompletion,
AssignmentTask,
CourseSessionAssignmentDetails,
CourseSessionUser,
LearningContentAssignment,
} from "@/types";
import { useMutation, useQuery } from "@urql/vue";
import { useRouteQuery } from "@vueuse/router";
import dayjs from "dayjs";
import * as log from "loglevel";
@ -22,16 +25,13 @@ import { useI18n } from "vue-i18n";
const { t } = useI18n();
const courseSessionsStore = useCourseSessionsStore();
const assignmentStore = useAssignmentStore();
const userStore = useUserStore();
interface State {
assignment: Assignment | undefined;
courseSessionAssignmentDetails: CourseSessionAssignmentDetails | undefined;
}
const state: State = reactive({
assignment: undefined,
courseSessionAssignmentDetails: undefined,
});
@ -39,12 +39,33 @@ const props = defineProps<{
learningContent: LearningContentAssignment;
}>();
// noinspection TypeScriptValidateTypes TODO: because of IntelliJ
const queryResult = useQuery({
query: ASSIGNMENT_COMPLETION_QUERY,
variables: {
courseSessionId: courseSessionsStore.currentCourseSession!.id.toString(),
assignmentId: props.learningContent.content_assignment_id.toString(),
},
pause: true,
});
const upsertAssignmentCompletionMutation = useMutation(
UPSERT_ASSIGNMENT_COMPLETION_MUTATION
);
// 0 = introduction, 1 - n = tasks, n+1 = submission
const stepIndex = useRouteQuery("step", "0", { transform: Number, mode: "push" });
const assignmentCompletion = computed(() => assignmentStore.assignmentCompletion);
const assignment = computed(
() => queryResult.data.value?.assignment as Assignment | undefined
);
const assignmentCompletion = computed(
() =>
queryResult.data.value?.assignment_completion as AssignmentCompletion | undefined
);
const completionStatus = computed(() => {
return assignmentCompletion.value?.completion_status ?? "in_progress";
return assignmentCompletion.value?.completion_status ?? "IN_PROGRESS";
});
onMounted(async () => {
@ -54,23 +75,24 @@ onMounted(async () => {
props.learningContent
);
const courseSessionsStore = useCourseSessionsStore();
// create initial `AssignmentCompletion` first, so that it exists and we don't
// have reactivity problem accessing it.
// noinspection TypeScriptValidateTypes
await upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.learningContent.content_assignment_id.toString(),
courseSessionId: courseSessionsStore.currentCourseSession!.id.toString(),
completionDataString: JSON.stringify({}),
completionStatus: "IN_PROGRESS",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
id: assignmentCompletion.value?.id,
});
queryResult.resume();
try {
state.assignment = await assignmentStore.loadAssignment(
props.learningContent.content_assignment_id
);
state.courseSessionAssignmentDetails = courseSessionsStore.findAssignmentDetails(
props.learningContent.id
);
await assignmentStore.loadAssignmentCompletion(
props.learningContent.content_assignment_id,
courseSessionId.value
);
if (
stepIndex.value === 0 &&
(completionStatus.value ?? "in_progress") !== "in_progress"
(completionStatus.value ?? "IN_PROGRESS") !== "IN_PROGRESS"
) {
stepIndex.value = numPages.value - 1;
}
@ -79,7 +101,7 @@ onMounted(async () => {
}
});
const numTasks = computed(() => state.assignment?.tasks?.length ?? 0);
const numTasks = computed(() => assignment.value?.tasks?.length ?? 0);
const numPages = computed(() => numTasks.value + 2);
const showPreviousButton = computed(() => stepIndex.value != 0);
const showNextButton = computed(() => stepIndex.value + 1 < numPages.value);
@ -92,7 +114,7 @@ const courseSessionId = computed(
);
const currentTask = computed(() => {
if (stepIndex.value > 0 && stepIndex.value <= numTasks.value) {
return state.assignment?.tasks[stepIndex.value - 1];
return assignment.value?.tasks[stepIndex.value - 1];
}
return undefined;
});
@ -115,7 +137,7 @@ const handleContinue = () => {
const jumpToTask = (task: AssignmentTask) => {
log.debug("jumpToTask", task);
const index = state.assignment?.tasks.findIndex((t) => t.id === task.id);
const index = assignment.value?.tasks.findIndex((t) => t.id === task.id);
if (index && index >= 0) {
stepIndex.value = index + 1;
}
@ -139,11 +161,11 @@ const assignmentUser = computed(() => {
</script>
<template>
<div v-if="state.assignment">
<div v-if="assignment && assignmentCompletion">
<div class="flex">
<LearningContentMultiLayout
:current-step="stepIndex"
:subtitle="state.assignment?.title ?? ''"
:subtitle="assignment.title ?? ''"
:title="getTitle()"
:learning-content-type="props.learningContent.content_type"
:steps-count="numPages"
@ -162,20 +184,21 @@ const assignmentUser = computed(() => {
<div class="flex">
<div>
<AssignmentIntroductionView
v-if="stepIndex === 0 && state.assignment"
v-if="stepIndex === 0"
:due-date="dueDate"
:assignment="state.assignment!"
:assignment="assignment"
></AssignmentIntroductionView>
<AssignmentTaskView
v-if="currentTask"
:task="currentTask"
:assignment-id="props.learningContent.content_assignment_id"
:assignment-completion="assignmentCompletion"
></AssignmentTaskView>
<AssignmentSubmissionView
v-if="stepIndex + 1 === numPages && state.assignment && courseSessionId"
v-if="stepIndex + 1 === numPages"
:due-date="dueDate"
:assignment="state.assignment!"
:assignment-completion-data="assignmentCompletion?.completion_data ?? {}"
:assignment="assignment"
:assignment-completion="assignmentCompletion"
:course-session-id="courseSessionId!"
@edit-task="jumpToTask($event)"
></AssignmentSubmissionView>
@ -183,13 +206,12 @@ const assignmentUser = computed(() => {
</div>
</LearningContentMultiLayout>
<div
v-if="assignmentCompletion?.completion_status === 'evaluation_submitted'"
v-if="assignmentCompletion?.completion_status === 'EVALUATION_SUBMITTED'"
class="min-w-2/5 mr-4 bg-gray-200 px-6 py-6"
>
<EvaluationSummary
v-if="state.assignment"
:assignment-user="assignmentUser"
:assignment="state.assignment"
:assignment="assignment"
:assignment-completion="assignmentCompletion"
:show-evaluation-user="true"
></EvaluationSummary>

View File

@ -2,6 +2,9 @@ import type { StatusCountKey } from "@/components/ui/ItProgress.vue";
import { itGet } from "@/fetchHelpers";
import type { LearningPath } from "@/services/learningPath";
import { useCockpitStore } from "@/stores/cockpit";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useLearningPathStore } from "@/stores/learningPath";
import { useUserStore } from "@/stores/user";
import type {
Assignment,
AssignmentCompletion,
@ -67,7 +70,7 @@ export function calcUserAssignmentCompletionStatus(
}
let progressStatus: StatusCountKey = "unknown";
if (
["submitted", "evaluation_in_progress", "evaluation_submitted"].includes(
["SUBMITTED", "EVALUATION_IN_PROGRESS", "EVALUATION_SUBMITTED"].includes(
userStatus
)
) {
@ -83,6 +86,30 @@ export function calcUserAssignmentCompletionStatus(
});
}
export function findAssignmentDetail(assignmentId: number) {
const learningPathStore = useLearningPathStore();
const userStore = useUserStore();
const courseSessionsStore = useCourseSessionsStore();
// TODO: filter by selected circle
if (!courseSessionsStore.currentCourseSession) {
return undefined;
}
const learningContents = calcAssignmentLearningContents(
learningPathStore.learningPathForUser(
courseSessionsStore.currentCourseSession.course.slug,
userStore.id
)
);
const learningContent = learningContents.find(
(lc) => lc.assignmentId === assignmentId
);
return courseSessionsStore.findAssignmentDetails(learningContent?.id);
}
export function maxAssignmentPoints(assignment: Assignment) {
return sum(assignment.evaluation_tasks.map((task) => task.value.max_points));
}

View File

@ -1,110 +0,0 @@
import { itGet, itPost } from "@/fetchHelpers";
import { calcAssignmentLearningContents } from "@/services/assignmentService";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useLearningPathStore } from "@/stores/learningPath";
import { useUserStore } from "@/stores/user";
import type {
Assignment,
AssignmentCompletion,
EvaluationCompletionData,
UpsertUserAssignmentCompletion,
} from "@/types";
import { merge } from "lodash";
import log from "loglevel";
import { defineStore } from "pinia";
export type AssignmentStoreState = {
assignment: Assignment | undefined;
assignmentCompletion: AssignmentCompletion | undefined;
};
export const useAssignmentStore = defineStore({
id: "assignmentStore",
state: () => {
return {
assignment: undefined,
assignmentCompletion: undefined,
} as AssignmentStoreState;
},
getters: {},
actions: {
async loadAssignment(assignmentId: number) {
log.debug("load assignment", assignmentId);
const assignmentData = await itGet(`/api/course/page/${assignmentId}/`);
if (!assignmentData) {
throw `No assignment found with: ${assignmentId}`;
}
this.assignment = assignmentData;
return this.assignment;
},
async loadAssignmentCompletion(
assignmentId: number,
courseSessionId: number,
userId: string | undefined = undefined
) {
log.debug("load assignment completion", assignmentId, courseSessionId, userId);
this.assignmentCompletion = undefined;
try {
let url = `/api/assignment/${assignmentId}/${courseSessionId}/`;
if (userId) {
url += `${userId}/`;
}
this.assignmentCompletion = await itGet(url);
} catch (e) {
log.debug("no completion data found ", e);
return undefined;
}
return this.assignmentCompletion;
},
getCompletionDataForUserInput(id: string) {
return this.assignmentCompletion?.completion_data[id];
},
async upsertAssignmentCompletion(data: UpsertUserAssignmentCompletion) {
if (this.assignmentCompletion) {
merge(this.assignmentCompletion.completion_data, data.completion_data);
this.assignmentCompletion.completion_status = data.completion_status;
}
const responseData = await itPost(`/api/assignment/upsert/`, data);
if (responseData) {
this.assignmentCompletion = responseData;
}
return responseData;
},
async evaluateAssignmentCompletion(data: EvaluationCompletionData) {
if (this.assignmentCompletion) {
merge(this.assignmentCompletion.completion_data, data.completion_data);
this.assignmentCompletion.completion_status = data.completion_status;
}
const responseData = await itPost(`/api/assignment/evaluate/`, data);
if (responseData) {
this.assignmentCompletion = responseData;
}
return responseData;
},
findAssignmentDetail(assignmentId: number) {
const learningPathStore = useLearningPathStore();
const userStore = useUserStore();
const courseSessionsStore = useCourseSessionsStore();
// TODO: filter by selected circle
if (!courseSessionsStore.currentCourseSession) {
return undefined;
}
const learningContents = calcAssignmentLearningContents(
learningPathStore.learningPathForUser(
courseSessionsStore.currentCourseSession.course.slug,
userStore.id
)
);
const learningContent = learningContents.find(
(lc) => lc.assignmentId === assignmentId
);
return courseSessionsStore.findAssignmentDetails(learningContent?.id);
},
},
});

View File

@ -305,9 +305,13 @@ export interface AssignmentTask {
}
export interface AssignmentEvaluationSubTask {
title: string;
description: string;
points: number;
readonly type: "task";
readonly id: string;
readonly value: {
title: string;
description: string;
points: number;
};
}
export interface AssignmentEvaluationTask {
@ -473,10 +477,10 @@ export interface Notification {
export type AssignmentCompletionStatus =
| "unknwown"
| "in_progress"
| "submitted"
| "evaluation_in_progress"
| "evaluation_submitted";
| "IN_PROGRESS"
| "SUBMITTED"
| "EVALUATION_IN_PROGRESS"
| "EVALUATION_SUBMITTED";
export interface UserDataText {
text: string;

View File

@ -74,7 +74,7 @@ describe("trainer test", () => {
"assignment_user_id",
TEST_STUDENT1_USER_ID
).then((ac) => {
expect(ac.completion_status).to.equal("evaluation_in_progress");
expect(ac.completion_status).to.equal("EVALUATION_IN_PROGRESS");
expect(JSON.stringify(ac.completion_data)).to.include("Nicht so gut");
expect(Cypress._.values(ac.completion_data)).to.deep.include({
expert_data: { points: 2, text: "Nicht so gut" },

View File

@ -1,72 +1,80 @@
/// <reference types="cypress" />
context('Actions', () => {
context("Actions", () => {
beforeEach(() => {
cy.visit('https://example.cypress.io/commands/actions')
})
cy.visit("https://example.cypress.io/commands/actions");
});
// https://on.cypress.io/interacting-with-elements
it('.type() - type into a DOM element', () => {
it(".type() - type into a DOM element", () => {
// https://on.cypress.io/type
cy.get('.action-email')
.type('fake@email.com').should('have.value', 'fake@email.com')
cy.get(".action-email")
.type("fake@email.com")
.should("have.value", "fake@email.com")
// .type() with special character sequences
.type('{leftarrow}{rightarrow}{uparrow}{downarrow}')
.type('{del}{selectall}{backspace}')
.type("{leftarrow}{rightarrow}{uparrow}{downarrow}")
.type("{del}{selectall}{backspace}")
// .type() with key modifiers
.type('{alt}{option}') //these are equivalent
.type('{ctrl}{control}') //these are equivalent
.type('{meta}{command}{cmd}') //these are equivalent
.type('{shift}')
.type("{alt}{option}") //these are equivalent
.type("{ctrl}{control}") //these are equivalent
.type("{meta}{command}{cmd}") //these are equivalent
.type("{shift}")
// Delay each keypress by 0.1 sec
.type('slow.typing@email.com', { delay: 100 })
.should('have.value', 'slow.typing@email.com')
.type("slow.typing@email.com", { delay: 100 })
.should("have.value", "slow.typing@email.com");
cy.get('.action-disabled')
cy.get(".action-disabled")
// Ignore error checking prior to type
// like whether the input is visible or disabled
.type('disabled error checking', { force: true })
.should('have.value', 'disabled error checking')
})
.type("disabled error checking", { force: true })
.should("have.value", "disabled error checking");
});
it('.focus() - focus on a DOM element', () => {
it(".focus() - focus on a DOM element", () => {
// https://on.cypress.io/focus
cy.get('.action-focus').focus()
.should('have.class', 'focus')
.prev().should('have.attr', 'style', 'color: orange;')
})
cy.get(".action-focus")
.focus()
.should("have.class", "focus")
.prev()
.should("have.attr", "style", "color: orange;");
});
it('.blur() - blur off a DOM element', () => {
it(".blur() - blur off a DOM element", () => {
// https://on.cypress.io/blur
cy.get('.action-blur').type('About to blur').blur()
.should('have.class', 'error')
.prev().should('have.attr', 'style', 'color: red;')
})
cy.get(".action-blur")
.type("About to blur")
.blur()
.should("have.class", "error")
.prev()
.should("have.attr", "style", "color: red;");
});
it('.clear() - clears an input or textarea element', () => {
it(".clear() - clears an input or textarea element", () => {
// https://on.cypress.io/clear
cy.get('.action-clear').type('Clear this text')
.should('have.value', 'Clear this text')
cy.get(".action-clear")
.type("Clear this text")
.should("have.value", "Clear this text")
.clear()
.should('have.value', '')
})
.should("have.value", "");
});
it('.submit() - submit a form', () => {
it(".submit() - submit a form", () => {
// https://on.cypress.io/submit
cy.get('.action-form')
.find('[type="text"]').type('HALFOFF')
cy.get(".action-form").find('[type="text"]').type("HALFOFF");
cy.get('.action-form').submit()
.next().should('contain', 'Your form has been submitted!')
})
cy.get(".action-form")
.submit()
.next()
.should("contain", "Your form has been SUBMITTED!");
});
it('.click() - click on a DOM element', () => {
it(".click() - click on a DOM element", () => {
// https://on.cypress.io/click
cy.get('.action-btn').click()
cy.get(".action-btn").click();
// You can click on 9 specific positions of an element:
// -----------------------------------
@ -82,169 +90,176 @@ context('Actions', () => {
// -----------------------------------
// clicking in the center of the element is the default
cy.get('#action-canvas').click()
cy.get("#action-canvas").click();
cy.get('#action-canvas').click('topLeft')
cy.get('#action-canvas').click('top')
cy.get('#action-canvas').click('topRight')
cy.get('#action-canvas').click('left')
cy.get('#action-canvas').click('right')
cy.get('#action-canvas').click('bottomLeft')
cy.get('#action-canvas').click('bottom')
cy.get('#action-canvas').click('bottomRight')
cy.get("#action-canvas").click("topLeft");
cy.get("#action-canvas").click("top");
cy.get("#action-canvas").click("topRight");
cy.get("#action-canvas").click("left");
cy.get("#action-canvas").click("right");
cy.get("#action-canvas").click("bottomLeft");
cy.get("#action-canvas").click("bottom");
cy.get("#action-canvas").click("bottomRight");
// .click() accepts an x and y coordinate
// that controls where the click occurs :)
cy.get('#action-canvas')
cy.get("#action-canvas")
.click(80, 75) // click 80px on x coord and 75px on y coord
.click(170, 75)
.click(80, 165)
.click(100, 185)
.click(125, 190)
.click(150, 185)
.click(170, 165)
.click(170, 165);
// click multiple elements by passing multiple: true
cy.get('.action-labels>.label').click({ multiple: true })
cy.get(".action-labels>.label").click({ multiple: true });
// Ignore error checking prior to clicking
cy.get('.action-opacity>.btn').click({ force: true })
})
cy.get(".action-opacity>.btn").click({ force: true });
});
it('.dblclick() - double click on a DOM element', () => {
it(".dblclick() - double click on a DOM element", () => {
// https://on.cypress.io/dblclick
// Our app has a listener on 'dblclick' event in our 'scripts.js'
// that hides the div and shows an input on double click
cy.get('.action-div').dblclick().should('not.be.visible')
cy.get('.action-input-hidden').should('be.visible')
})
cy.get(".action-div").dblclick().should("not.be.visible");
cy.get(".action-input-hidden").should("be.visible");
});
it('.rightclick() - right click on a DOM element', () => {
it(".rightclick() - right click on a DOM element", () => {
// https://on.cypress.io/rightclick
// Our app has a listener on 'contextmenu' event in our 'scripts.js'
// that hides the div and shows an input on right click
cy.get('.rightclick-action-div').rightclick().should('not.be.visible')
cy.get('.rightclick-action-input-hidden').should('be.visible')
})
cy.get(".rightclick-action-div").rightclick().should("not.be.visible");
cy.get(".rightclick-action-input-hidden").should("be.visible");
});
it('.check() - check a checkbox or radio element', () => {
it(".check() - check a checkbox or radio element", () => {
// https://on.cypress.io/check
// By default, .check() will check all
// matching checkbox or radio elements in succession, one after another
cy.get('.action-checkboxes [type="checkbox"]').not('[disabled]')
.check().should('be.checked')
cy.get('.action-checkboxes [type="checkbox"]')
.not("[disabled]")
.check()
.should("be.checked");
cy.get('.action-radios [type="radio"]').not('[disabled]')
.check().should('be.checked')
cy.get('.action-radios [type="radio"]')
.not("[disabled]")
.check()
.should("be.checked");
// .check() accepts a value argument
cy.get('.action-radios [type="radio"]')
.check('radio1').should('be.checked')
.check("radio1")
.should("be.checked");
// .check() accepts an array of values
cy.get('.action-multiple-checkboxes [type="checkbox"]')
.check(['checkbox1', 'checkbox2']).should('be.checked')
.check(["checkbox1", "checkbox2"])
.should("be.checked");
// Ignore error checking prior to checking
cy.get('.action-checkboxes [disabled]')
.check({ force: true }).should('be.checked')
cy.get(".action-checkboxes [disabled]")
.check({ force: true })
.should("be.checked");
cy.get('.action-radios [type="radio"]')
.check('radio3', { force: true }).should('be.checked')
})
.check("radio3", { force: true })
.should("be.checked");
});
it('.uncheck() - uncheck a checkbox element', () => {
it(".uncheck() - uncheck a checkbox element", () => {
// https://on.cypress.io/uncheck
// By default, .uncheck() will uncheck all matching
// checkbox elements in succession, one after another
cy.get('.action-check [type="checkbox"]')
.not('[disabled]')
.uncheck().should('not.be.checked')
.not("[disabled]")
.uncheck()
.should("not.be.checked");
// .uncheck() accepts a value argument
cy.get('.action-check [type="checkbox"]')
.check('checkbox1')
.uncheck('checkbox1').should('not.be.checked')
.check("checkbox1")
.uncheck("checkbox1")
.should("not.be.checked");
// .uncheck() accepts an array of values
cy.get('.action-check [type="checkbox"]')
.check(['checkbox1', 'checkbox3'])
.uncheck(['checkbox1', 'checkbox3']).should('not.be.checked')
.check(["checkbox1", "checkbox3"])
.uncheck(["checkbox1", "checkbox3"])
.should("not.be.checked");
// Ignore error checking prior to unchecking
cy.get('.action-check [disabled]')
.uncheck({ force: true }).should('not.be.checked')
})
cy.get(".action-check [disabled]")
.uncheck({ force: true })
.should("not.be.checked");
});
it('.select() - select an option in a <select> element', () => {
it(".select() - select an option in a <select> element", () => {
// https://on.cypress.io/select
// at first, no option should be selected
cy.get('.action-select')
.should('have.value', '--Select a fruit--')
cy.get(".action-select").should("have.value", "--Select a fruit--");
// Select option(s) with matching text content
cy.get('.action-select').select('apples')
cy.get(".action-select").select("apples");
// confirm the apples were selected
// note that each value starts with "fr-" in our HTML
cy.get('.action-select').should('have.value', 'fr-apples')
cy.get(".action-select").should("have.value", "fr-apples");
cy.get('.action-select-multiple')
.select(['apples', 'oranges', 'bananas'])
cy.get(".action-select-multiple")
.select(["apples", "oranges", "bananas"])
// when getting multiple values, invoke "val" method first
.invoke('val')
.should('deep.equal', ['fr-apples', 'fr-oranges', 'fr-bananas'])
.invoke("val")
.should("deep.equal", ["fr-apples", "fr-oranges", "fr-bananas"]);
// Select option(s) with matching value
cy.get('.action-select').select('fr-bananas')
cy.get(".action-select")
.select("fr-bananas")
// can attach an assertion right away to the element
.should('have.value', 'fr-bananas')
.should("have.value", "fr-bananas");
cy.get('.action-select-multiple')
.select(['fr-apples', 'fr-oranges', 'fr-bananas'])
.invoke('val')
.should('deep.equal', ['fr-apples', 'fr-oranges', 'fr-bananas'])
cy.get(".action-select-multiple")
.select(["fr-apples", "fr-oranges", "fr-bananas"])
.invoke("val")
.should("deep.equal", ["fr-apples", "fr-oranges", "fr-bananas"]);
// assert the selected values include oranges
cy.get('.action-select-multiple')
.invoke('val').should('include', 'fr-oranges')
})
cy.get(".action-select-multiple")
.invoke("val")
.should("include", "fr-oranges");
});
it('.scrollIntoView() - scroll an element into view', () => {
it(".scrollIntoView() - scroll an element into view", () => {
// https://on.cypress.io/scrollintoview
// normally all of these buttons are hidden,
// because they're not within
// the viewable area of their parent
// (we need to scroll to see them)
cy.get('#scroll-horizontal button')
.should('not.be.visible')
cy.get("#scroll-horizontal button").should("not.be.visible");
// scroll the button into view, as if the user had scrolled
cy.get('#scroll-horizontal button').scrollIntoView()
.should('be.visible')
cy.get("#scroll-horizontal button").scrollIntoView().should("be.visible");
cy.get('#scroll-vertical button')
.should('not.be.visible')
cy.get("#scroll-vertical button").should("not.be.visible");
// Cypress handles the scroll direction needed
cy.get('#scroll-vertical button').scrollIntoView()
.should('be.visible')
cy.get("#scroll-vertical button").scrollIntoView().should("be.visible");
cy.get('#scroll-both button')
.should('not.be.visible')
cy.get("#scroll-both button").should("not.be.visible");
// Cypress knows to scroll to the right and down
cy.get('#scroll-both button').scrollIntoView()
.should('be.visible')
})
cy.get("#scroll-both button").scrollIntoView().should("be.visible");
});
it('.trigger() - trigger an event on a DOM element', () => {
it(".trigger() - trigger an event on a DOM element", () => {
// https://on.cypress.io/trigger
// To interact with a range input (slider)
@ -253,14 +268,15 @@ context('Actions', () => {
// Here, we invoke jQuery's val() method to set
// the value and trigger the 'change' event
cy.get('.trigger-input-range')
.invoke('val', 25)
.trigger('change')
.get('input[type=range]').siblings('p')
.should('have.text', '25')
})
cy.get(".trigger-input-range")
.invoke("val", 25)
.trigger("change")
.get("input[type=range]")
.siblings("p")
.should("have.text", "25");
});
it('cy.scrollTo() - scroll the window or element to a position', () => {
it("cy.scrollTo() - scroll the window or element to a position", () => {
// https://on.cypress.io/scrollto
// You can scroll to 9 specific positions of an element:
@ -278,22 +294,22 @@ context('Actions', () => {
// if you chain .scrollTo() off of cy, we will
// scroll the entire window
cy.scrollTo('bottom')
cy.scrollTo("bottom");
cy.get('#scrollable-horizontal').scrollTo('right')
cy.get("#scrollable-horizontal").scrollTo("right");
// or you can scroll to a specific coordinate:
// (x axis, y axis) in pixels
cy.get('#scrollable-vertical').scrollTo(250, 250)
cy.get("#scrollable-vertical").scrollTo(250, 250);
// or you can scroll to a specific percentage
// of the (width, height) of the element
cy.get('#scrollable-both').scrollTo('75%', '25%')
cy.get("#scrollable-both").scrollTo("75%", "25%");
// control the easing of the scroll (default is 'swing')
cy.get('#scrollable-vertical').scrollTo('center', { easing: 'linear' })
cy.get("#scrollable-vertical").scrollTo("center", { easing: "linear" });
// control the duration of the scroll (in ms)
cy.get('#scrollable-both').scrollTo('center', { duration: 2000 })
})
})
cy.get("#scrollable-both").scrollTo("center", { duration: 2000 });
});
});

View File

@ -98,7 +98,7 @@ THIRD_PARTY_APPS = [
"modelcluster",
"taggit",
"storages",
"grapple",
# "grapple",
"graphene_django",
"notifications",
"django_jsonform",
@ -566,11 +566,22 @@ OAUTH = {
},
}
GRAPHENE = {"SCHEMA": "grapple.schema.schema", "SCHEMA_OUTPUT": "schema.graphql"}
GRAPPLE = {
"EXPOSE_GRAPHIQL": DEBUG,
"APPS": ["core", "course", "learnpath", "competence", "media_library"],
GRAPHENE = {
"SCHEMA": "vbv_lernwelt.core.schema.schema",
"SCHEMA_OUTPUT": "schema.graphql",
}
# GRAPPLE = {
# "EXPOSE_GRAPHIQL": DEBUG,
# "AUTO_CAMELCASE": False,
# "APPS": [
# "assignment",
# "competence",
# "core",
# "course",
# "learnpath",
# "media_library",
# ],
# }
# Notifications
# django-notifications

View File

@ -7,7 +7,8 @@ from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path, register_converter
from django.urls.converters import IntConverter
from django.views import defaults as default_views
from grapple import urls as grapple_urls
from django.views.decorators.csrf import csrf_exempt
from graphene_django.views import GraphQLView
from ratelimit.exceptions import Ratelimited
from vbv_lernwelt.assignment.views import (
@ -18,6 +19,7 @@ from vbv_lernwelt.assignment.views import (
upsert_user_assignment_completion,
)
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.core.schema import schema
from vbv_lernwelt.core.views import (
check_rate_limit,
cypress_reset_view,
@ -151,12 +153,15 @@ urlpatterns = [
path(r'api/core/feedback/<str:course_id>/<str:circle_id>/', get_feedback_for_circle,
name='feedback_for_circle'),
path("server/graphql/",
csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))),
# testing and debug
path('server/raise_error/',
user_passes_test(lambda u: u.is_superuser, login_url='/login/')(
raise_example_error), ),
path("server/checkratelimit/", check_rate_limit),
path("server/", include(grapple_urls)),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,96 @@
import json
import graphene
import structlog
from rest_framework.exceptions import PermissionDenied
from vbv_lernwelt.assignment.graphql.types import AssignmentCompletionType
from vbv_lernwelt.assignment.models import Assignment
from vbv_lernwelt.assignment.services import update_assignment_completion
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course.permissions import has_course_access, is_course_session_expert
logger = structlog.get_logger(__name__)
class AssignmentCompletionMutation(graphene.Mutation):
assignment_completion = graphene.Field(AssignmentCompletionType)
class Input:
assignment_id = graphene.ID(required=True)
course_session_id = graphene.ID(required=True)
assignment_user_id = graphene.ID()
completion_status = graphene.String()
completion_data_string = graphene.String()
evaluation_grade = graphene.Float()
evaluation_points = graphene.Float()
@classmethod
def mutate(
cls,
root,
info,
assignment_id,
course_session_id,
assignment_user_id=None,
completion_status="IN_PROGRESS",
completion_data_string="{}",
evaluation_grade=None,
evaluation_points=None,
):
if assignment_user_id is None:
assignment_user_id = info.context.user.id
assignment_user = User.objects.get(id=assignment_user_id)
assignment = Assignment.objects.get(id=assignment_id)
if not has_course_access(
info.context.user,
assignment.get_course().id,
):
raise PermissionDenied()
assignment_data = {
"assignment_user": assignment_user,
"assignment": assignment,
"course_session": CourseSession.objects.get(id=course_session_id),
"completion_data": json.loads(completion_data_string),
"completion_status": completion_status,
}
evaluation_data = {}
if completion_status in ["EVALUATION_SUBMITTED", "EVALUATION_IN_PROGRESS"]:
if not is_course_session_expert(info.context.user, course_session_id):
raise PermissionDenied()
evaluation_data = {
"evaluation_user": info.context.user,
"evaluation_grade": evaluation_grade,
"evaluation_points": evaluation_points,
}
ac = update_assignment_completion(
copy_task_data=False,
**assignment_data,
**evaluation_data,
)
logger.debug(
"AssignmentCompletionMutation successful",
label="assignment_api",
assignment_id=assignment.id,
assignment_title=assignment.title,
assignment_user_id=assignment_user_id,
course_session_id=course_session_id,
completion_status=completion_status,
)
return AssignmentCompletionMutation(assignment_completion=ac)
class AssignmentMutation:
upsert_assignment_completion = AssignmentCompletionMutation.Field()

View File

@ -0,0 +1,45 @@
import graphene
from rest_framework.exceptions import PermissionDenied
from vbv_lernwelt.assignment.graphql.types import (
AssignmentCompletionType,
AssignmentType,
)
from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
from vbv_lernwelt.course.graphql.types import resolve_course_page
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course.permissions import has_course_access, is_course_session_expert
class AssignmentQuery(object):
assignment = graphene.Field(
AssignmentType, id=graphene.ID(), slug=graphene.String()
)
def resolve_assignment(root, info, id=None, slug=None):
return resolve_course_page(Assignment, root, info, id=id, slug=slug)
assignment_completion = graphene.Field(
AssignmentCompletionType,
assignment_id=graphene.ID(required=True),
course_session_id=graphene.ID(required=True),
assignment_user_id=graphene.ID(required=False),
)
def resolve_assignment_completion(
root, info, assignment_id, course_session_id, assignment_user_id=None, **kwargs
):
if assignment_user_id is None:
assignment_user_id = info.context.user.id
if str(assignment_user_id) == str(
info.context.user.id
) or is_course_session_expert(info.context.user, course_session_id):
course_id = CourseSession.objects.get(id=course_session_id).course_id
if has_course_access(info.context.user, course_id):
return AssignmentCompletion.objects.filter(
assignment_user_id=assignment_user_id,
assignment_id=assignment_id,
course_session_id=course_session_id,
).first()
raise PermissionDenied()

View File

@ -0,0 +1,45 @@
from graphene.types.generic import GenericScalar
from graphene_django import DjangoObjectType
from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
from vbv_lernwelt.core.graphql.types import JSONStreamField
from vbv_lernwelt.course.schema import CoursePageInterface
class AssignmentType(DjangoObjectType):
tasks = JSONStreamField()
evaluation_tasks = JSONStreamField()
performance_objectives = JSONStreamField()
class Meta:
model = Assignment
interfaces = (CoursePageInterface,)
fields = (
"starting_position",
"effort_required",
"evaluation_description",
"evaluation_document_url",
)
class AssignmentCompletionType(DjangoObjectType):
completion_data = GenericScalar()
class Meta:
model = AssignmentCompletion
fields = (
"id",
"created_at",
"updated_at",
"submitted_at",
"evaluation_submitted_at",
"assignment_user",
"assignment",
"course_session",
"completion_status",
"completion_data",
"evaluation_user",
"additional_json_data",
"evaluation_grade",
"evaluation_points",
)

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.db.models.deletion
import wagtail.blocks
@ -243,12 +243,12 @@ class Migration(migrations.Migration):
"completion_status",
models.CharField(
choices=[
(1, "in_progress"),
(2, "submitted"),
(3, "evaluation_in_progress"),
(4, "evaluation_submitted"),
("IN_PROGRESS", "IN_PROGRESS"),
("SUBMITTED", "SUBMITTED"),
("EVALUATION_IN_PROGRESS", "EVALUATION_IN_PROGRESS"),
("EVALUATION_SUBMITTED", "EVALUATION_SUBMITTED"),
],
default="in_progress",
default="IN_PROGRESS",
max_length=255,
),
),
@ -293,12 +293,12 @@ class Migration(migrations.Migration):
"completion_status",
models.CharField(
choices=[
(1, "in_progress"),
(2, "submitted"),
(3, "evaluation_in_progress"),
(4, "evaluation_submitted"),
(1, "IN_PROGRESS"),
(2, "SUBMITTED"),
(3, "EVALUATION_IN_PROGRESS"),
(4, "EVALUATION_SUBMITTED"),
],
default="in_progress",
default="IN_PROGRESS",
max_length=255,
),
),

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.db.models.deletion
from django.conf import settings
@ -10,8 +10,8 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("assignment", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.db.models.deletion
from django.conf import settings
@ -10,9 +10,9 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("assignment", "0002_assignmentcompletionauditlog_assignment_user"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("course", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("assignment", "0002_assignmentcompletionauditlog_assignment_user"),
]
operations = [

View File

@ -223,10 +223,14 @@ class Assignment(CourseBasePage):
AssignmentCompletionStatus = Enum(
"AssignmentCompletionStatus",
["in_progress", "submitted", "evaluation_in_progress", "evaluation_submitted"],
["IN_PROGRESS", "SUBMITTED", "EVALUATION_IN_PROGRESS", "EVALUATION_SUBMITTED"],
)
def is_valid_assignment_completion_status(status):
return status in AssignmentCompletionStatus.__members__
class AssignmentCompletion(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@ -249,8 +253,8 @@ class AssignmentCompletion(models.Model):
completion_status = models.CharField(
max_length=255,
choices=[(acs.value, acs.name) for acs in AssignmentCompletionStatus],
default="in_progress",
choices=[(acs.name, acs.name) for acs in AssignmentCompletionStatus],
default="IN_PROGRESS",
)
completion_data = models.JSONField(default=dict)
@ -267,7 +271,7 @@ class AssignmentCompletion(models.Model):
class AssignmentCompletionAuditLog(models.Model):
"""
This model is used to store the "submitted" and "evaluation_submitted" data separately
This model is used to store the "SUBMITTED" and "EVALUATION_SUBMITTED" data separately
"""
created_at = models.DateTimeField(auto_now_add=True)
@ -288,7 +292,7 @@ class AssignmentCompletionAuditLog(models.Model):
completion_status = models.CharField(
max_length=255,
choices=[(acs.value, acs.name) for acs in AssignmentCompletionStatus],
default="in_progress",
default="IN_PROGRESS",
)
completion_data = models.JSONField(default=dict)

View File

@ -0,0 +1,3 @@
import structlog
logger = structlog.get_logger(__name__)

View File

@ -9,6 +9,7 @@ from vbv_lernwelt.assignment.models import (
AssignmentCompletion,
AssignmentCompletionAuditLog,
AssignmentCompletionStatus,
is_valid_assignment_completion_status,
)
from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.utils import find_first
@ -20,7 +21,7 @@ def update_assignment_completion(
assignment: Assignment,
course_session: CourseSession,
completion_data=None,
completion_status: Type[AssignmentCompletionStatus] = "in_progress",
completion_status: Type[AssignmentCompletionStatus] = "IN_PROGRESS",
evaluation_user: User | None = None,
evaluation_grade: float | None = None,
evaluation_points: float | None = None,
@ -42,7 +43,7 @@ def update_assignment_completion(
},
}
:param copy_task_data: if true, the task data will be copied to the completion data
used for "submitted" and "evaluation_submitted" status, so that we don't lose the question
used for "SUBMITTED" and "EVALUATION_SUBMITTED" status, so that we don't lose the question
context
:return: AssignmentCompletion
"""
@ -55,63 +56,68 @@ def update_assignment_completion(
course_session_id=course_session.id,
)
if not is_valid_assignment_completion_status(completion_status):
raise serializers.ValidationError(
{"completion_status": f"Invalid completion status {completion_status}"}
)
if validate_completion_status_change:
# TODO: check time?
if completion_status == "submitted":
if completion_status == "SUBMITTED":
if ac.completion_status in [
"submitted",
"evaluation_in_progress",
"evaluation_submitted",
"SUBMITTED",
"EVALUATION_IN_PROGRESS",
"EVALUATION_SUBMITTED",
]:
raise serializers.ValidationError(
{
"completion_status": f"Cannot update completion status from {ac.completion_status} to submitted"
"completion_status": f"Cannot update completion status from {ac.completion_status} to SUBMITTED"
}
)
elif completion_status == "evaluation_submitted":
if ac.completion_status == "evaluation_submitted":
elif completion_status == "EVALUATION_SUBMITTED":
if ac.completion_status == "EVALUATION_SUBMITTED":
raise serializers.ValidationError(
{
"completion_status": f"Cannot update completion status from {ac.completion_status} to evaluation_submitted"
"completion_status": f"Cannot update completion status from {ac.completion_status} to EVALUATION_SUBMITTED"
}
)
if completion_status == "in_progress" and ac.completion_status != "in_progress":
if completion_status == "IN_PROGRESS" and ac.completion_status != "IN_PROGRESS":
raise serializers.ValidationError(
{
"completion_status": f"Cannot set completion status to in_progress when it is {ac.completion_status}"
"completion_status": f"Cannot set completion status to IN_PROGRESS when it is {ac.completion_status}"
}
)
if completion_status in ["evaluation_submitted", "evaluation_in_progress"]:
if completion_status in ["EVALUATION_SUBMITTED", "EVALUATION_IN_PROGRESS"]:
if evaluation_user is None:
raise serializers.ValidationError(
{
"evaluation_user": "evaluation_user is required for evaluation_submitted status"
"evaluation_user": "evaluation_user is required for EVALUATION_SUBMITTED status"
}
)
ac.evaluation_user = evaluation_user
if completion_status == "evaluation_submitted":
if completion_status == "EVALUATION_SUBMITTED":
if evaluation_grade is None:
raise serializers.ValidationError(
{
"evaluation_grade": "evaluation_grade is required for evaluation_submitted status"
"evaluation_grade": "evaluation_grade is required for EVALUATION_SUBMITTED status"
}
)
if evaluation_points is None:
raise serializers.ValidationError(
{
"evaluation_points": "evaluation_points is required for evaluation_submitted status"
"evaluation_points": "evaluation_points is required for EVALUATION_SUBMITTED status"
}
)
ac.evaluation_grade = evaluation_grade
ac.evaluation_points = evaluation_points
if completion_status == "submitted":
if completion_status == "SUBMITTED":
ac.submitted_at = timezone.now()
elif completion_status == "evaluation_submitted":
elif completion_status == "EVALUATION_SUBMITTED":
ac.evaluation_submitted_at = timezone.now()
ac.completion_status = completion_status
@ -134,7 +140,7 @@ def update_assignment_completion(
ac.save()
if completion_status in ["evaluation_submitted", "submitted"]:
if completion_status in ["EVALUATION_SUBMITTED", "SUBMITTED"]:
acl = AssignmentCompletionAuditLog.objects.create(
assignment_user=assignment_user,
assignment=assignment,

View File

@ -52,7 +52,7 @@ class AssignmentApiTestCase(APITestCase):
{
"assignment_id": self.assignment.id,
"course_session_id": self.cs.id,
"completion_status": "in_progress",
"completion_status": "IN_PROGRESS",
"completion_data": {
user_text_input["id"]: {"user_data": {"text": "Hallo via API"}},
},
@ -65,7 +65,7 @@ class AssignmentApiTestCase(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response_json["assignment_user"], self.student.id)
self.assertEqual(response_json["assignment"], self.assignment.id)
self.assertEqual(response_json["completion_status"], "in_progress")
self.assertEqual(response_json["completion_status"], "IN_PROGRESS")
self.assertDictEqual(
response_json["completion_data"],
{
@ -78,7 +78,7 @@ class AssignmentApiTestCase(APITestCase):
course_session_id=self.cs.id,
assignment_id=self.assignment.id,
)
self.assertEqual(db_entry.completion_status, "in_progress")
self.assertEqual(db_entry.completion_status, "IN_PROGRESS")
self.assertDictEqual(
db_entry.completion_data,
{
@ -106,7 +106,7 @@ class AssignmentApiTestCase(APITestCase):
{
"assignment_id": self.assignment.id,
"course_session_id": self.cs.id,
"completion_status": "submitted",
"completion_status": "SUBMITTED",
"completion_data": {
user_text_input["id"]: {"user_data": {"text": "Hallo via API 2"}},
},
@ -119,7 +119,7 @@ class AssignmentApiTestCase(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response_json["assignment_user"], self.student.id)
self.assertEqual(response_json["assignment"], self.assignment.id)
self.assertEqual(response_json["completion_status"], "submitted")
self.assertEqual(response_json["completion_status"], "SUBMITTED")
self.assertDictEqual(
response_json["completion_data"],
{
@ -133,7 +133,7 @@ class AssignmentApiTestCase(APITestCase):
{
"assignment_id": self.assignment.id,
"course_session_id": self.cs.id,
"completion_status": "submitted",
"completion_status": "SUBMITTED",
"completion_data": {
user_text_input["id"]: {"user_data": {"text": "Hallo via API 2"}},
},
@ -162,7 +162,7 @@ class AssignmentApiTestCase(APITestCase):
assignment_user=self.student,
assignment=self.assignment,
course_session=self.cs,
completion_status="submitted",
completion_status="SUBMITTED",
submitted_at=timezone.now(),
completion_data={
user_text_input["id"]: {
@ -181,7 +181,7 @@ class AssignmentApiTestCase(APITestCase):
"assignment_id": self.assignment.id,
"assignment_user_id": self.student.id,
"course_session_id": self.cs.id,
"completion_status": "evaluation_in_progress",
"completion_status": "EVALUATION_IN_PROGRESS",
"completion_data": {
user_text_input["id"]: {
"expert_data": {"points": 1, "comment": "Gut gemacht!"}
@ -196,7 +196,7 @@ class AssignmentApiTestCase(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response_json["assignment_user"], self.student.id)
self.assertEqual(response_json["assignment"], self.assignment.id)
self.assertEqual(response_json["completion_status"], "evaluation_in_progress")
self.assertEqual(response_json["completion_status"], "EVALUATION_IN_PROGRESS")
self.assertDictEqual(
response_json["completion_data"],
{
@ -212,7 +212,7 @@ class AssignmentApiTestCase(APITestCase):
course_session_id=self.cs.id,
assignment_id=self.assignment.id,
)
self.assertEqual(db_entry.completion_status, "evaluation_in_progress")
self.assertEqual(db_entry.completion_status, "EVALUATION_IN_PROGRESS")
self.assertDictEqual(
db_entry.completion_data,
{
@ -230,7 +230,7 @@ class AssignmentApiTestCase(APITestCase):
"assignment_id": self.assignment.id,
"assignment_user_id": self.student.id,
"course_session_id": self.cs.id,
"completion_status": "evaluation_submitted",
"completion_status": "EVALUATION_SUBMITTED",
"completion_data": {
user_text_input["id"]: {
"expert_data": {"points": 1, "comment": "Gut gemacht!"}
@ -247,7 +247,7 @@ class AssignmentApiTestCase(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response_json["assignment_user"], self.student.id)
self.assertEqual(response_json["assignment"], self.assignment.id)
self.assertEqual(response_json["completion_status"], "evaluation_submitted")
self.assertEqual(response_json["completion_status"], "EVALUATION_SUBMITTED")
self.assertDictEqual(
response_json["completion_data"],
{
@ -263,7 +263,7 @@ class AssignmentApiTestCase(APITestCase):
course_session_id=self.cs.id,
assignment_id=self.assignment.id,
)
self.assertEqual(db_entry.completion_status, "evaluation_submitted")
self.assertEqual(db_entry.completion_status, "EVALUATION_SUBMITTED")
self.assertDictEqual(
db_entry.completion_data,
{
@ -274,12 +274,12 @@ class AssignmentApiTestCase(APITestCase):
},
)
# `evaluation_submitted` will create a new AssignmentCompletionAuditLog
# `EVALUATION_SUBMITTED` will create a new AssignmentCompletionAuditLog
acl = AssignmentCompletionAuditLog.objects.get(
assignment_user=self.student,
course_session_id=self.cs.id,
assignment_id=self.assignment.id,
completion_status="evaluation_submitted",
completion_status="EVALUATION_SUBMITTED",
)
self.maxDiff = None
self.assertDictEqual(

View File

@ -159,7 +159,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="submitted",
completion_status="SUBMITTED",
)
ac = AssignmentCompletion.objects.get(
@ -168,7 +168,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
course_session=self.course_session,
)
self.assertEqual(ac.completion_status, "submitted")
self.assertEqual(ac.completion_status, "SUBMITTED")
self.assertEqual(ac.submitted_at.date(), date.today())
# will create AssignmentCompletionAuditLog entry
@ -176,7 +176,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="submitted",
completion_status="SUBMITTED",
)
self.assertEqual(acl.created_at.date(), date.today())
self.assertEqual(acl.assignment_user_email, "student")
@ -211,7 +211,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment=self.assignment,
course_session=self.course_session,
submitted_at=timezone.now(),
completion_status="submitted",
completion_status="SUBMITTED",
completion_data={
user_text_input0["id"]: {
"user_data": {"text": "Am Anfang war das Wort... 0"}
@ -227,7 +227,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="submitted",
completion_status="SUBMITTED",
)
# can submit twice with flag
@ -235,7 +235,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="submitted",
completion_status="SUBMITTED",
validate_completion_status_change=False,
)
@ -245,7 +245,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
course_session=self.course_session,
)
self.assertEqual(ac.completion_status, "submitted")
self.assertEqual(ac.completion_status, "SUBMITTED")
self.assertEqual(ac.submitted_at.date(), date.today())
def test_copy_task_data(self):
@ -275,7 +275,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="submitted",
completion_status="SUBMITTED",
copy_task_data=True,
)
@ -285,7 +285,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
course_session=self.course_session,
)
self.assertEqual(ac.completion_status, "submitted")
self.assertEqual(ac.completion_status, "SUBMITTED")
user_input = ac.completion_data[user_text_input["id"]]
self.assertEqual(
user_input["user_data"]["text"], "Ich würde nichts weiteres empfehlen."
@ -301,7 +301,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="submitted",
completion_status="SUBMITTED",
)
evaluation_task = self.assignment.get_evaluation_tasks()[0]
@ -315,7 +315,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
"expert_data": {"points": 2, "text": "Gut gemacht!"}
},
},
completion_status="evaluation_in_progress",
completion_status="EVALUATION_IN_PROGRESS",
evaluation_user=self.trainer,
)
@ -325,7 +325,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
course_session=self.course_session,
)
self.assertEqual(ac.completion_status, "evaluation_in_progress")
self.assertEqual(ac.completion_status, "EVALUATION_IN_PROGRESS")
trainer_input = ac.completion_data[evaluation_task["id"]]
self.assertDictEqual(
trainer_input["expert_data"], {"points": 2, "text": "Gut gemacht!"}
@ -347,7 +347,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="submitted",
completion_status="SUBMITTED",
completion_data={
user_text_input["id"]: {
"user_data": {"text": "Ich würde nichts weiteres empfehlen."}
@ -364,7 +364,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
"expert_data": {"points": 1, "comment": "Gut gemacht!"}
},
},
completion_status="evaluation_in_progress",
completion_status="EVALUATION_IN_PROGRESS",
evaluation_user=self.trainer,
)
@ -374,7 +374,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
course_session=self.course_session,
)
self.assertEqual(ac.completion_status, "evaluation_in_progress")
self.assertEqual(ac.completion_status, "EVALUATION_IN_PROGRESS")
user_input = ac.completion_data[user_text_input["id"]]
self.assertDictEqual(
user_input["expert_data"], {"points": 1, "comment": "Gut gemacht!"}
@ -399,7 +399,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="in_progress",
completion_status="IN_PROGRESS",
completion_data={
user_text_input["id"]: {
"user_data": {"text": "Ich würde nichts weiteres empfehlen."}
@ -412,7 +412,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="evaluation_in_progress",
completion_status="EVALUATION_IN_PROGRESS",
completion_data={
user_text_input["id"]: {
"expert_data": {"points": 1, "comment": "Gut gemacht!"}
@ -440,7 +440,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="submitted",
completion_status="SUBMITTED",
completion_data={
user_text_input["id"]: {
"user_data": {"text": "Ich würde nichts weiteres empfehlen."}
@ -459,7 +459,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
"expert_data": {"points": 2, "text": "Gut gemacht!"}
},
},
completion_status="evaluation_in_progress",
completion_status="EVALUATION_IN_PROGRESS",
evaluation_user=self.trainer,
)
@ -470,7 +470,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment=self.assignment,
course_session=self.course_session,
completion_data={},
completion_status="evaluation_submitted",
completion_status="EVALUATION_SUBMITTED",
evaluation_user=self.trainer,
evaluation_grade=None,
evaluation_points=None,
@ -481,7 +481,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment=self.assignment,
course_session=self.course_session,
completion_data={},
completion_status="evaluation_submitted",
completion_status="EVALUATION_SUBMITTED",
evaluation_user=self.trainer,
evaluation_grade=4.5,
evaluation_points=16,
@ -493,7 +493,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
course_session=self.course_session,
)
self.assertEqual(ac.completion_status, "evaluation_submitted")
self.assertEqual(ac.completion_status, "EVALUATION_SUBMITTED")
self.assertEqual(ac.evaluation_grade, 4.5)
self.assertEqual(ac.evaluation_points, 16)
trainer_input = ac.completion_data[evaluation_task["id"]]
@ -510,7 +510,7 @@ class UpdateAssignmentCompletionTestCase(TestCase):
assignment_user=self.user,
assignment=self.assignment,
course_session=self.course_session,
completion_status="evaluation_submitted",
completion_status="EVALUATION_SUBMITTED",
)
self.assertEqual(acl.created_at.date(), date.today())
self.assertEqual(acl.assignment_user_email, "student")

View File

@ -82,7 +82,7 @@ def upsert_user_assignment_completion(request):
try:
assignment_id = request.data.get("assignment_id")
course_session_id = request.data.get("course_session_id")
completion_status = request.data.get("completion_status", "in_progress")
completion_status = request.data.get("completion_status", "IN_PROGRESS")
completion_data = request.data.get("completion_data", {})
assignment_page = Page.objects.get(id=assignment_id)
@ -126,7 +126,7 @@ def evaluate_assignment_completion(request):
assignment_user_id = request.data.get("assignment_user_id")
course_session_id = request.data.get("course_session_id")
completion_status = request.data.get(
"completion_status", "evaluation_in_progress"
"completion_status", "EVALUATION_IN_PROGRESS"
)
completion_data = request.data.get("completion_data", {})
evaluation_grade = request.data.get("evaluation_grade", None)

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.db.models.deletion
import wagtail.blocks

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.db.models.deletion
from django.db import migrations, models
@ -9,8 +9,8 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("learnpath", "0001_initial"),
("competence", "0001_initial"),
("learnpath", "0001_initial"),
]
operations = [

View File

@ -0,0 +1,28 @@
import json
from graphene.types.generic import GenericScalar
from graphene_django import DjangoObjectType
from vbv_lernwelt.core.models import User
class UserType(DjangoObjectType):
class Meta:
model = User
fields = (
"id",
"username",
"first_name",
"last_name",
"email",
"avatar_url",
"language",
)
class JSONStreamField(GenericScalar):
@staticmethod
def serialize(stream_value):
if stream_value is None:
return None
return json.loads(json.dumps([block for block in stream_value.raw_data]))

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.contrib.auth.validators
import django.utils.timezone

View File

@ -0,0 +1,17 @@
import graphene
from vbv_lernwelt.assignment.graphql.mutations import AssignmentMutation
from vbv_lernwelt.assignment.graphql.queries import AssignmentQuery
from vbv_lernwelt.course.schema import CourseQuery
from vbv_lernwelt.feedback.graphql.mutations import FeedbackMutation
class Query(AssignmentQuery, CourseQuery, graphene.ObjectType):
pass
class Mutation(AssignmentMutation, FeedbackMutation, graphene.ObjectType):
pass
schema = graphene.Schema(query=Query, mutation=Mutation, auto_camelcase=False)

View File

@ -1,11 +1,19 @@
from django.contrib import admin
from django.db.models import JSONField
from vbv_lernwelt.core.admin_utils import PrettyJSONWidget
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser
from vbv_lernwelt.learnpath.models import Circle
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
list_display = [
"id",
"title",
"category_name",
"slug",
]
@admin.register(CourseSession)
class CourseSessionAdmin(admin.ModelAdmin):
date_hierarchy = "created_at"

View File

@ -127,7 +127,7 @@ def create_test_assignment_submitted_data(assignment, course_session, user):
assignment_user=user,
assignment=assignment,
course_session=course_session,
completion_status="submitted",
completion_status="SUBMITTED",
)

View File

@ -0,0 +1,16 @@
import graphene
from graphql import GraphQLError
from vbv_lernwelt.course.graphql.types import CourseType
from vbv_lernwelt.course.models import Course
from vbv_lernwelt.course.permissions import has_course_access
class CourseQuery:
course = graphene.Field(CourseType, id=graphene.Int())
def resolve_course(root, info, id):
course = Course.objects.get(pk=id)
if has_course_access(info.context.user, course):
return course
return GraphQLError("You do not have access to this course")

View File

@ -0,0 +1,50 @@
from typing import Type
import graphene
import structlog
from graphene_django import DjangoObjectType
from graphql import GraphQLError
from rest_framework.exceptions import PermissionDenied
from vbv_lernwelt.course.models import Course, CourseBasePage
from vbv_lernwelt.course.permissions import has_course_access
from vbv_lernwelt.course.schema import CoursePageInterface
logger = structlog.get_logger(__name__)
def resolve_course_page(
page_model_class: Type[CourseBasePage], root, info, id=None, slug=None
):
try:
if id is None and slug is None:
raise GraphQLError("Either 'id' or 'slug' must be provided.")
page = None
if id is not None:
page = page_model_class.objects.get(pk=id)
elif slug is not None:
page = page_model_class.objects.get(slug=slug)
if page and not has_course_access(
info.context.user, page.specific.get_course().id
):
raise PermissionDenied("You do not have access to this course.")
return page
except PermissionDenied as e:
raise e
except Exception as e:
logger.exception(e)
raise e
class CourseType(DjangoObjectType):
learning_path = graphene.Field(CoursePageInterface)
class Meta:
model = Course
fields = ("id", "title", "category_name", "slug")
def resolve_learning_path(self, info):
return self.get_learning_path()

View File

@ -329,7 +329,7 @@ def create_course_uk_de_assignment_completion_data(assignment, course_session, u
assignment_user=user,
assignment=assignment,
course_session=course_session,
completion_status="submitted",
completion_status="SUBMITTED",
)

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.db.models.deletion
import django_jsonform.models.fields

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.db.models.deletion
from django.conf import settings
@ -10,10 +10,10 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("learnpath", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("files", "0001_initial"),
("course", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("learnpath", "0001_initial"),
]
operations = [

View File

@ -3,6 +3,7 @@ from django.db.models import UniqueConstraint
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_jsonform.models.fields import JSONField
from grapple.models import GraphQLString
from wagtail.models import Page
from vbv_lernwelt.core.model_utils import find_available_slug
@ -26,13 +27,13 @@ class Course(models.Model):
def get_course_url(self):
return f"/course/{self.slug}"
def get_learning_path_url(self):
def get_learning_path(self):
from vbv_lernwelt.learnpath.models import LearningPath
learning_path_page = (
self.coursepage.get_children().exact_type(LearningPath).first()
)
return learning_path_page.specific.get_frontend_url()
return self.coursepage.get_children().exact_type(LearningPath).first().specific
def get_learning_path_url(self):
return self.get_learning_path().get_frontend_url()
def get_competence_url(self):
from vbv_lernwelt.competence.models import CompetenceProfilePage
@ -78,6 +79,16 @@ class CourseBasePage(Page):
"frontend_url",
]
graphql_fields = [
GraphQLString(
field_name="frontend_url",
source="get_graphql_frontend_url",
)
]
def get_graphql_frontend_url(self, values):
return self.get_frontend_url()
def get_course_parent(self):
return self.get_ancestors(inclusive=True).exact_type(CoursePage).last()

View File

@ -0,0 +1,46 @@
import graphene
from graphene_django import DjangoObjectType
from vbv_lernwelt.course.models import Course
from vbv_lernwelt.course.permissions import has_course_access
from vbv_lernwelt.learnpath.models import LearningPath
class CoursePageInterface(graphene.Interface):
id = graphene.ID()
title = graphene.String()
slug = graphene.String()
content_type = graphene.String()
live = graphene.Boolean()
translation_key = graphene.String()
frontend_url = graphene.String()
def resolve_frontend_url(self, info):
return self.get_frontend_url()
class LearningPathType(DjangoObjectType):
class Meta:
model = LearningPath
interfaces = (CoursePageInterface,)
class CourseType(DjangoObjectType):
learning_path = graphene.Field(LearningPathType)
class Meta:
model = Course
fields = ("id", "title", "category_name", "slug", "learning_path")
def resolve_learning_path(self, info):
return self.get_learning_path()
class CourseQuery(graphene.ObjectType):
course = graphene.Field(CourseType, id=graphene.Int())
def resolve_course(root, info, id):
course = Course.objects.get(pk=id)
if has_course_access(info.context.user, course):
return course
raise PermissionError("You do not have access to this course")

View File

@ -53,5 +53,5 @@ class SendFeedback(ClientIDMutation):
return SendFeedback(feedback_response=feedback_response)
class Mutation(object):
class FeedbackMutation(object):
send_feedback = SendFeedback.Field()

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
from django.db import migrations, models

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.db.models.deletion
from django.db import migrations, models
@ -9,9 +9,9 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("course", "0001_initial"),
("learnpath", "0001_initial"),
("feedback", "0001_initial"),
("course", "0002_initial"),
]
operations = [

View File

@ -1,8 +0,0 @@
from wagtail.core import hooks
from .graphql.mutations import Mutation
@hooks.register("register_schema_mutation")
def register_feedback_mutations(mutation_mixins):
mutation_mixins.append(Mutation)

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.db.models.deletion
import wagtail.fields
@ -10,9 +10,9 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("course", "0001_initial"),
("assignment", "0003_initial"),
("wagtailcore", "0083_workflowcontenttype"),
("course", "0001_initial"),
]
operations = [

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2023-05-11 20:06
# Generated by Django 3.2.13 on 2023-05-12 15:29
import django.db.models.deletion
import taggit.managers
@ -15,10 +15,10 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("course", "0002_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("wagtailcore", "0083_workflowcontenttype"),
("course", "0002_initial"),
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
]
operations = [