Merge branch 'feature/circle-vue' into develop

This commit is contained in:
Daniel Egger 2022-06-27 16:01:19 +02:00
commit 1e3c75d171
40 changed files with 1306 additions and 388 deletions

View File

@ -47,6 +47,7 @@ cap.create_and_update_app(
'IT_DJANGO_SECRET_KEY': env.str('IT_DJANGO_SECRET_KEY'),
'IT_DJANGO_ADMIN_URL': env.str('IT_DJANGO_ADMIN_URL'),
'IT_DJANGO_ALLOWED_HOSTS': env.str('IT_DJANGO_ALLOWED_HOSTS'),
'IT_DJANGO_DEBUG': 'false',
'IT_SENTRY_DSN': env.str('IT_SENTRY_DSN'),
'IT_APP_ENVIRONMENT': 'caprover',
'POSTGRES_HOST': 'srv-captain--vbv-lernwelt-postgres-db',

View File

@ -1,5 +1,8 @@
#!/bin/bash
# script should fail when any process returns non zero code
set -ev
# create client
npm run build

View File

@ -1,28 +1,18 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
require('@rushstack/eslint-patch/modern-module-resolution');
module.exports = {
"root": true,
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-typescript/recommended",
"@vue/eslint-config-prettier"
'root': true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript/recommended',
// "@vue/eslint-config-prettier"
],
"env": {
"vue/setup-compiler-macros": true
'env': {
'vue/setup-compiler-macros': true
},
"overrides": [
{
"files": [
"cypress/integration/**.spec.{js,ts,jsx,tsx}"
],
"extends": [
"plugin:cypress/recommended"
]
}
],
"rules": {
"quotes": ["error", "single"]
'rules': {
'@typescript-eslint/no-unused-vars': ['warn'],
}
}

View File

@ -4,10 +4,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build && cp ./dist/index.html ../server/vbv_lernwelt/templates/vue/index.html && cp -r ./dist/static/vue ../server/vbv_lernwelt/static/",
"preview": "vite preview --port 5050",
"test:unit": "vitest --environment jsdom",
"test:e2e": "start-server-and-test preview http://127.0.0.1:5050/ 'cypress open'",
"test:e2e:ci": "start-server-and-test preview http://127.0.0.1:5050/ 'cypress run'",
"test": "vitest run",
"coverage": "vitest run --coverage",
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
@ -26,6 +24,7 @@
"@intlify/vite-plugin-vue-i18n": "^3.4.0",
"@rollup/plugin-alias": "^3.1.9",
"@rushstack/eslint-patch": "^1.1.0",
"@testing-library/vue": "^6.6.0",
"@types/jsdom": "^16.2.14",
"@types/node": "^16.11.26",
"@vitejs/plugin-vue": "^2.3.1",
@ -38,7 +37,7 @@
"eslint": "^8.5.0",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-vue": "^8.2.0",
"jsdom": "^19.0.0",
"happy-dom": "^5.3.1",
"postcss": "^8.4.12",
"postcss-import": "^14.1.0",
"prettier": "^2.5.1",
@ -47,7 +46,7 @@
"start-server-and-test": "^1.14.0",
"typescript": "~4.6.3",
"vite": "^2.9.1",
"vitest": "^0.8.1",
"vitest": "^0.15.1",
"vue-tsc": "^0.33.9"
}
}

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: {
'postcss-import': {},
autoprefixer: {},
},
}

View File

@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest'
import {describe, expect, it} from 'vitest'
import { mount } from '@vue/test-utils'
import {mount} from '@vue/test-utils'
import MainNavigationBar from '../MainNavigationBar.vue'
describe('MainNavigationBar', () => {
it('renders properly', () => {
const wrapper = mount(MainNavigationBar, { })
expect(wrapper.text()).toContain('Ich bin ein myVBV Heade')
const wrapper = mount(MainNavigationBar, {})
expect(wrapper.text()).toContain('myVBV')
})
})

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import type {Circle} from '@/types';
const props = defineProps<{
circleData: Circle
}>()
</script>
<template>
<div class="circle-overview px-4 py-16 lg:px-16 lg:py-24 relative">
<div
class="w-8 h-8 absolute right-4 top-4 cursor-pointer"
@click="$emit('close')"
>
<it-icon-close></it-icon-close>
</div>
<h1 class="">Überblick: Circle "{{ circleData.title }}"</h1>
<p class="mt-8 text-xl">Hier zeigen wir dir, was du in diesem Circle lernen wirst.</p>
<div class="mt-8 p-4 border border-gray-500">
<h3>Du wirst in der Lage sein, ... </h3>
<ul class="mt-4">
<li class="text-xl flex items-center" v-for="goal in circleData.goals" :key="goal.id">
<it-icon-check class="mt-4 hidden lg:block w-12 h-12 text-sky-500 flex-none"></it-icon-check>
<div class="mt-4">{{ goal.value }}</div>
</li>
</ul>
</div>
<h3 class="mt-16">
Du wirst dein neu erworbenes Wissen auf folgenden berufstypischen Situation anwenden können:
</h3>
<ul class="grid grid-cols-1 lg:grid-cols-3 auto-rows-fr gap-6 mt-8">
<li
v-for="jobSituation in circleData.job_situations"
:key="jobSituation.id"
class="job-situation border border-gray-500 p-4 text-xl flex items-center"
>
{{jobSituation.value}}
</li>
</ul>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
import * as log from 'loglevel';
import {computed} from 'vue';
import {useCircleStore} from '@/stores/circle';
log.debug('LearningContent.vue setup');
const circleStore = useCircleStore();
const learningContent = computed(() => circleStore.currentLearningContent);
const block = computed(() => {
if (learningContent.value) {
return learningContent.value.contents[0];
}
})
</script>
<template>
<div>
<nav
class="
px-4 lg:px-8
py-4
flex justify-between items-center
border-b border-gray-500
"
>
<button
type="button"
class="btn-text inline-flex items-center px-3 py-2 font-normal"
@click="circleStore.closeLearningContent()"
>
<it-icon-arrow-left class="-ml-1 mr-1 h-5 w-5"></it-icon-arrow-left>
<span class="hidden lg:inline">zurück zum Circle</span>
</button>
<h1 class="text-xl hidden lg:block">{{ learningContent.title }}</h1>
<button
type="button"
class="btn-blue"
@click="circleStore.continueFromLearningContent()"
>
Abschliessen und weiter
</button>
</nav>
<div class="mx-auto max-w-5xl px-4 lg:px-8 py-4">
<p>{{ block.value.description }}</p>
<div v-if="block.type === 'video'">
<iframe
class="mt-8 w-full aspect-video"
:src="block.value.url"
:title="learningContent.title"
frameborder="0"
allow="accelerometer; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>
<div
v-if="block.type === 'podcast'"
>
<iframe width="100%" height="300" scrolling="no" frameborder="no" allow="" :src="block.value.url"></iframe>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -1,17 +1,28 @@
<script setup lang="ts">
import ItCheckbox from '@/components/ui/ItCheckbox.vue';
import type {LearningContent, LearningSequence} from '@/types';
import {useCircleStore} from '@/stores/circle';
import {computed} from 'vue';
const props = defineProps(['learningSequence', 'completionData'])
const props = defineProps<{
learningSequence: LearningSequence
}>()
const contentCompleted = (learningContent) => {
if (props.completionData?.json_data?.completed_learning_contents) {
return learningContent.translation_key in props.completionData.json_data.completed_learning_contents;
const circleStore = useCircleStore();
function toggleCompleted(learningContent: LearningContent) {
circleStore.markCompletion(learningContent, !learningContent.completed);
}
const someFinished = computed(() => {
if (props.learningSequence) {
return circleStore.flatChildren.filter((lc) => {
return lc.completed && lc.parentLearningSequence?.translation_key === props.learningSequence.translation_key;
}).length > 0;
}
return false;
}
})
</script>
@ -25,10 +36,17 @@ const contentCompleted = (learningContent) => {
<div>{{ learningSequence.minutes }} Minuten</div>
</div>
<div class="bg-white px-4 border border-gray-500 border-l-4 border-l-sky-500">
<div
class="bg-white px-4 border border-gray-500 border-l-4"
:class="{
'border-l-sky-500': someFinished,
'border-l-gray-500': !someFinished,
}"
>
<div
v-for="learningUnit in learningSequence.learningUnits"
class="py-3"
:key="learningUnit.id"
class="pt-3"
>
<div class="pb-3 flex gap-4" v-if="learningUnit.title">
<div class="font-bold">{{ learningUnit.title }}</div>
@ -37,17 +55,46 @@ const contentCompleted = (learningContent) => {
<div
v-for="learningContent in learningUnit.learningContents"
:key="learningContent.id"
class="flex items-center gap-4 pb-3"
>
<ItCheckbox
:modelValue="contentCompleted(learningContent)"
@click="$emit('toggleLearningContentCheckbox', learningContent)"
:modelValue="learningContent.completed"
@click="toggleCompleted(learningContent)"
>
{{ learningContent.contents[0].type }}: {{ learningContent.title }}
<span @click.stop="circleStore.openLearningContent(learningContent)">{{ learningContent.contents[0].type }}: {{ learningContent.title }}</span>
</ItCheckbox>
</div>
<hr class="-mx-4 text-gray-500">
<div
v-if="learningUnit.id"
class="hover:cursor-pointer"
@click="circleStore.openSelfEvaluation(learningUnit)"
>
<div
v-if="circleStore.calcSelfEvaluationStatus(learningUnit)"
class="flex items-center gap-4 pb-3"
>
<it-icon-smiley-happy/>
<span>Selbsteinschätzung: Ich kann das.</span>
</div>
<div
v-else-if="circleStore.calcSelfEvaluationStatus(learningUnit) === false"
class="flex items-center gap-4 pb-3"
>
<it-icon-smiley-thinking/>
<span>Selbsteinschätzung: Muss ich nochmals anschauen</span>
</div>
<div
v-else
class="flex items-center gap-4 pb-3"
>
<it-icon-smiley-neutral/>
<span>Selbsteinschätzung</span>
</div>
</div>
<hr v-if="!learningUnit.last" class="-mx-4 text-gray-500">
</div>
</div>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import * as log from 'loglevel';
import {computed, reactive} from 'vue';
import {useCircleStore} from '@/stores/circle';
log.debug('LearningContent.vue setup');
const circleStore = useCircleStore();
const state = reactive({
questionIndex: 0,
});
const questions = computed(() => circleStore.currentSelfEvaluation!.children);
const currentQuestion = computed(() => questions.value[state.questionIndex]);
function handleContinue() {
log.debug('handleContinue');
if (state.questionIndex + 1 < questions.value.length) {
log.debug('increment questionIndex', state.questionIndex);
state.questionIndex += 1;
} else {
log.debug('continue to next learning content');
circleStore.continueFromSelfEvaluation();
}
}
</script>
<template>
<div>
<nav
class="
px-4 lg:px-8
py-4
flex justify-between items-center
border-b border-gray-500
"
>
<button
type="button"
class="btn-text inline-flex items-center px-3 py-2 font-normal"
@click="circleStore.closeSelfEvaluation()"
>
<it-icon-arrow-left class="-ml-1 mr-1 h-5 w-5"></it-icon-arrow-left>
<span class="hidden lg:inline">zurück zum Circle</span>
</button>
<h1 class="text-xl hidden lg:block">{{ circleStore.currentSelfEvaluation.title }}</h1>
<button
type="button"
class="btn-blue"
@click="handleContinue()"
>
Weiter
</button>
</nav>
<div class="mx-auto max-w-6xl px-4 lg:px-8 py-4">
<div class="mt-2 lg:mt-8 text-gray-700">Schritt {{ state.questionIndex + 1 }} von {{ questions.length }}</div>
<p class="text-xl mt-4">
Überprüfe, ob du in der Lernheinheit <span class="font-bold">"{{ circleStore.currentSelfEvaluation.title }}"</span> alles verstanden hast.<br>
Lies die folgende Aussage und bewerte sie:
</p>
<div class="mt-4 lg:mt-8 p-6 lg:p-12 border border-gray-500">
<h2 class="heading-2">{{ currentQuestion.title }}</h2>
<div class="mt-4 lg:mt-8 flex flex-col lg:flex-row justify-between gap-6">
<button
@click="circleStore.markCompletion(currentQuestion, true)"
class="flex-1 inline-flex items-center text-left p-4 border"
:class="{
'border-green-500': currentQuestion.completed,
'border-2': currentQuestion.completed,
'border-gray-500': !currentQuestion.completed,
}"
>
<it-icon-smiley-happy class="w-16 h-16 mr-4"></it-icon-smiley-happy>
<span class="font-bold text-xl">
Ja, ich kann das.
</span>
</button>
<button
@click="circleStore.markCompletion(currentQuestion, false)"
class="flex-1 inline-flex items-center text-left p-4 border"
:class="{
'border-orange-500': currentQuestion.completed === false,
'border-2': currentQuestion.completed === false,
'border-gray-500': currentQuestion.completed === true || currentQuestion.completed === undefined,
}"
>
<it-icon-smiley-thinking class="w-16 h-16 mr-4"></it-icon-smiley-thinking>
<span class="font-bold text-xl">
Das muss ich nochmals anschauen.
</span>
</button>
</div>
<div class="mt-6 lg:mt-12">Schau dein Fortschritt in deinem Kompetenzprofil: Kompetenzprofil öffnen</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -1,13 +1,15 @@
import { getCookieValue } from '@/router/guards';
import {getCookieValue} from '@/router/guards';
class FetchError extends Error {
constructor(response, message = 'HTTP error ' + response.status) {
response: Response;
constructor(response: Response, message = 'HTTP error ' + response.status) {
super(message);
this.response = response;
}
}
export const itFetch = (url, options) => {
export const itFetch = (url: RequestInfo, options: RequestInit) => {
return fetch(url, options).then(response => {
if (!response.ok) {
throw new FetchError(response);
@ -17,7 +19,11 @@ export const itFetch = (url, options) => {
});
};
export const itPost = (url, data, options) => {
export const itPost = (
url: RequestInfo,
data: unknown,
options: RequestInit = {},
) => {
options = Object.assign({}, options);
const headers = Object.assign({
@ -35,6 +41,8 @@ export const itPost = (url, data, options) => {
body: JSON.stringify(data)
}, options);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
options.headers['X-CSRFToken'] = getCookieValue('csrftoken');
if (options.method === 'GET') {
@ -48,6 +56,6 @@ export const itPost = (url, data, options) => {
});
};
export const itGet = (url) => {
export const itGet = (url: RequestInfo) => {
return itPost(url, {}, {method: 'GET'});
};

View File

@ -0,0 +1,130 @@
import {describe, it} from 'vitest'
import {parseLearningSequences} from '../circle';
import type {WagtailCircle} from '@/types';
describe('circleService.parseLearningSequences', () => {
it('can parse learning sequences from api response', () => {
const input = {
"id": 10,
"title": "Analyse",
"slug": "analyse",
"type": "learnpath.Circle",
"translation_key": "c9832aaf-02b2-47af-baeb-bde60d8ec1f5",
"children": [
{
"id": 18,
"title": "Anwenden",
"slug": "anwenden",
"type": "learnpath.LearningSequence",
"translation_key": "2e4c431a-9602-4398-ad18-20dd4bb189fa",
"icon": "it-icon-ls-apply"
},
{
"id": 19,
"title": "Prämien einsparen",
"slug": "pramien-einsparen",
"type": "learnpath.LearningUnit",
"translation_key": "75c1f31a-ae25-4d9c-9206-a4e7fdae8c13",
"questions": []
},
{
"id": 20,
"title": "Versicherungsbedarf für Familien",
"slug": "versicherungsbedarf-für-familien",
"type": "learnpath.LearningContent",
"translation_key": "2a422da3-a3ad-468a-831e-9141c122ffef",
"minutes": 60,
"contents": [
{
"type": "exercise",
"value": {
"description": "Beispiel Aufgabe"
},
"id": "ee0bcef7-702b-42f3-a891-88a0332fce6f"
}
]
},
{
"id": 21,
"title": "Alles klar?",
"slug": "alles-klar",
"type": "learnpath.LearningContent",
"translation_key": "7dc9d96d-07f9-4b9f-bec1-43ba67cf9010",
"minutes": 60,
"contents": [
{
"type": "exercise",
"value": {
"description": "Beispiel Aufgabe"
},
"id": "a556ebb2-f902-4d78-9b76-38b7933118b8"
}
]
},
{
"id": 22,
"title": "Sich selbständig machen",
"slug": "sich-selbstandig-machen",
"type": "learnpath.LearningUnit",
"translation_key": "c40d5266-3c94-4b9b-8469-9ac6b32a6231",
"questions": []
},
{
"id": 23,
"title": "GmbH oder AG",
"slug": "gmbh-oder-ag",
"type": "learnpath.LearningContent",
"translation_key": "59331843-9f52-4b41-9cd1-2293a8d90064",
"minutes": 120,
"contents": [
{
"type": "video",
"value": {
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
},
"id": "a4974834-f404-4fb8-af94-a24c6db56bb8"
}
]
},
{
"id": 24,
"title": "Tiertherapie Patrizia Feller",
"slug": "tiertherapie-patrizia-feller",
"type": "learnpath.LearningContent",
"translation_key": "13f6d661-1d10-4b59-b8e5-01fcec47a38f",
"minutes": 120,
"contents": [
{
"type": "exercise",
"value": {
"description": "Beispiel Aufgabe"
},
"id": "5947c947-8656-44b5-826c-1787057c2df2"
}
]
},
{
"id": 25,
"title": "Auto verkaufen",
"slug": "auto-verkaufen",
"type": "learnpath.LearningUnit",
"translation_key": "3b42e514-0bbe-4c23-9c88-3f5263e47cf9",
"questions": []
},
],
"description": "Nach dem Gespräch werten sie die Analyse aus...",
"job_situations": [],
"goals": [],
"experts": []
} as WagtailCircle;
const learningSequences = parseLearningSequences(input.children);
expect(learningSequences.length).toBe(1);
console.log(learningSequences[0].learningUnits[0].learningContents[0]);
expect(
learningSequences[0].learningUnits[1].learningContents[0].previousLearningContent.translation_key
).toEqual(learningSequences[0].learningUnits[0].learningContents[1].translation_key);
})
})

View File

@ -0,0 +1,100 @@
import type {CircleChild, LearningContent, LearningSequence, LearningUnit} from '@/types';
function createEmptyLearningUnit(parentLearningSequence: LearningSequence): LearningUnit {
return {
id: 0,
title: '',
slug: '',
translation_key: '',
type: 'learnpath.LearningUnit',
learningContents: [],
minutes: 0,
parentLearningSequence: parentLearningSequence,
children: [],
last: true,
};
}
export function parseLearningSequences (children: CircleChild[]): LearningSequence[] {
let learningSequence:LearningSequence | undefined;
let learningUnit:LearningUnit | undefined;
let learningContent:LearningContent | undefined;
let previousLearningContent: LearningContent | undefined;
const result:LearningSequence[] = [];
children.forEach((child) => {
if (child.type === 'learnpath.LearningSequence') {
if (learningSequence) {
if (learningUnit) {
learningUnit.last = true;
learningSequence.learningUnits.push(learningUnit);
}
result.push(learningSequence);
}
learningSequence = Object.assign(child, {learningUnits: []});
// initialize empty learning unit if there will not come a learning unit next
learningUnit = createEmptyLearningUnit(learningSequence);
} else if (child.type === 'learnpath.LearningUnit') {
if (!learningSequence) {
throw new Error('LearningUnit found before LearningSequence');
}
if (learningUnit && learningUnit.learningContents.length) {
learningSequence.learningUnits.push(learningUnit);
}
learningUnit = Object.assign(child, {
learningContents: [],
parentLearningSequence: learningSequence,
children: child.children.map((c) => {
c.parentLearningUnit = learningUnit;
c.parentLearningSequence = learningSequence;
return c;
})
});
} else if (child.type === 'learnpath.LearningContent') {
if (!learningUnit) {
throw new Error('LearningContent found before LearningUnit');
}
previousLearningContent = learningContent;
learningContent = Object.assign(child, {
parentLearningSequence: learningSequence,
parentLearningUnit: learningUnit,
previousLearningContent: previousLearningContent,
});
if (previousLearningContent) {
previousLearningContent.nextLearningContent = learningContent;
}
learningUnit.learningContents.push(child);
}
});
if (learningUnit && learningSequence) {
// TypeScript does not get it here...
learningUnit.last = true;
(learningSequence as LearningSequence).learningUnits.push(learningUnit);
result.push(learningSequence);
} else {
throw new Error('Finished with LearningContent but there is no LearningSequence and LearningUnit');
}
// sum minutes
result.forEach((learningSequence) => {
learningSequence.minutes = 0;
learningSequence.learningUnits.forEach((learningUnit) => {
learningUnit.minutes = 0;
learningUnit.learningContents.forEach((learningContent) => {
learningUnit.minutes += learningContent.minutes;
});
learningSequence.minutes += learningUnit.minutes;
});
});
return result;
}

145
client/src/stores/circle.ts Normal file
View File

@ -0,0 +1,145 @@
import * as log from 'loglevel';
import {defineStore} from 'pinia'
import type {Circle, CircleChild, CircleCompletion, LearningContent, LearningUnit, LearningUnitQuestion} from '@/types'
import {itGet, itPost} from '@/fetchHelpers';
import {parseLearningSequences} from '@/services/circle';
export type CircleStoreState = {
circleData: Circle;
completionData: CircleCompletion[];
currentLearningContent: LearningContent | undefined;
currentSelfEvaluation: LearningUnit | undefined;
page: 'INDEX' | 'OVERVIEW' | 'LEARNING_CONTENT' | 'SELF_EVALUATION';
}
export const useCircleStore = defineStore({
id: 'circle',
state: () => {
return {
circleData: {},
completionData: {},
currentLearningContent: undefined,
currentSelfEvaluation: undefined,
page: 'INDEX',
} as CircleStoreState;
},
getters: {
flatChildren: (state) => {
const result:CircleChild[] = [];
state.circleData.learningSequences.forEach((learningSequence) => {
learningSequence.learningUnits.forEach((learningUnit) => {
learningUnit.children.forEach((learningUnitQuestion) => {
result.push(learningUnitQuestion);
})
learningUnit.learningContents.forEach((learningContent) => {
result.push(learningContent);
});
});
});
return result;
},
},
actions: {
async loadCircle(slug: string) {
try {
this.circleData = await itGet(`/learnpath/api/circle/${slug}/`);
this.circleData.learningSequences = parseLearningSequences(this.circleData.children);
this.completionData = await itGet(`/api/completion/circle/${this.circleData.translation_key}/`);
this.parseCompletionData();
} catch (error) {
log.error(error);
return error
}
},
async markCompletion(page: LearningContent | LearningUnitQuestion, flag = true) {
try {
page.completed = flag;
this.completionData = await itPost('/api/completion/circle/mark/', {
page_key: page.translation_key,
completed: page.completed,
});
this.parseCompletionData();
} catch (error) {
log.error(error);
return error
}
},
parseCompletionData() {
this.flatChildren.forEach((page) => {
const pageIndex = this.completionData.findIndex((e) => {
return e.page_key === page.translation_key;
});
if (pageIndex >= 0) {
page.completed = this.completionData[pageIndex].completed;
} else {
page.completed = undefined;
}
});
},
openLearningContent(learningContent: LearningContent) {
this.currentLearningContent = learningContent;
this.page = 'LEARNING_CONTENT';
},
closeLearningContent() {
this.currentLearningContent = undefined;
this.page = 'INDEX';
},
openSelfEvaluation(learningUnit: LearningUnit) {
this.page = 'SELF_EVALUATION';
this.currentSelfEvaluation = learningUnit;
},
closeSelfEvaluation() {
this.page = 'INDEX';
this.currentSelfEvaluation = undefined;
},
calcSelfEvaluationStatus(learningUnit: LearningUnit) {
if (learningUnit.children.length > 0) {
if (learningUnit.children.every((q) => q.completed)) {
return true;
}
if (learningUnit.children.some((q) => q.completed !== undefined)) {
return false;
}
}
return undefined;
},
continueFromLearningContent() {
if (this.currentLearningContent) {
this.markCompletion(this.currentLearningContent, true);
const nextLearningContent = this.currentLearningContent.nextLearningContent;
const currentParent = this.currentLearningContent.parentLearningUnit;
const nextParent = nextLearningContent?.parentLearningUnit;
if (
currentParent && currentParent.id &&
currentParent.id !== nextParent?.id &&
currentParent.children.length > 0
) {
// go to self evaluation
this.openSelfEvaluation(currentParent);
} else if (this.currentLearningContent.nextLearningContent) {
this.openLearningContent(this.currentLearningContent.nextLearningContent);
} else {
this.closeLearningContent();
}
} else {
log.error('currentLearningContent is undefined');
}
},
continueFromSelfEvaluation() {
if (this.currentSelfEvaluation) {
const nextContent = this.currentSelfEvaluation.learningContents[this.currentSelfEvaluation.learningContents.length - 1].nextLearningContent;
if (nextContent) {
this.openLearningContent(nextContent);
} else {
this.closeSelfEvaluation();
}
} else {
log.error('currentSelfEvaluation is undefined');
}
}
}
})

115
client/src/types.ts Normal file
View File

@ -0,0 +1,115 @@
export interface LearningContentBlock {
type: 'web-based-training' | 'competence' | 'exercise' | 'knowledge';
value: {
description: string;
},
id: string;
}
export interface VideoBlock {
type: 'video';
value: {
description: string;
url: string;
},
id: string;
}
export interface PodcastBlock {
type: 'podcast';
value: {
description: string;
url: string;
},
id: string;
}
export interface DocumentBlock {
type: 'document';
value: {
description: string;
url: string;
},
id: string;
}
export interface CircleGoal {
type: 'goal';
value: string;
id: string;
}
export interface CircleJobSituation {
type: 'job_situation';
value: string;
id: string;
}
export interface LearningWagtailPage {
id: number;
title: string;
slug: string;
translation_key: string;
completed?: boolean;
}
export interface LearningContent extends LearningWagtailPage {
type: 'learnpath.LearningContent';
minutes: number;
contents: (LearningContentBlock | VideoBlock | PodcastBlock | DocumentBlock)[];
parentLearningSequence?: LearningSequence;
parentLearningUnit?: LearningUnit;
nextLearningContent?: LearningContent;
previousLearningContent?: LearningContent;
}
export interface LearningUnitQuestion extends LearningWagtailPage {
type: 'learnpath.LearningUnitQuestion';
parentLearningSequence?: LearningSequence;
parentLearningUnit?: LearningUnit;
}
export interface LearningUnit extends LearningWagtailPage {
type: 'learnpath.LearningUnit';
learningContents: LearningContent[];
minutes: number;
parentLearningSequence?: LearningSequence;
children: LearningUnitQuestion[];
last?: boolean;
}
export interface LearningSequence extends LearningWagtailPage {
type: 'learnpath.LearningSequence';
icon: string;
learningUnits: LearningUnit[];
minutes: number;
}
export type CircleChild = LearningContent | LearningUnit | LearningSequence | LearningUnitQuestion;
export interface WagtailCircle extends LearningWagtailPage {
type: 'learnpath.Circle';
children: CircleChild[];
description: string;
}
export interface CircleCompletion {
id: number;
created_at: string;
updated_at: string;
user: number;
page_key: string;
page_type: string;
circle_key: string;
completed: boolean;
json_data: any;
}
export interface Circle extends LearningWagtailPage {
type: 'learnpath.Circle';
children: CircleChild[];
description: string;
learningSequences: LearningSequence[];
goals: CircleGoal[];
job_situations: CircleJobSituation[];
}

View File

@ -1,137 +1,119 @@
<script>
<script setup lang="ts">
import * as log from 'loglevel';
import MainNavigationBar from '../components/MainNavigationBar.vue';
import LearningSequence from '../components/circle/LearningSequence.vue';
import { itGet, itPost } from '../fetchHelpers';
import MainNavigationBar from '@/components/MainNavigationBar.vue';
import LearningSequence from '@/components/circle/LearningSequence.vue';
import CircleOverview from '@/components/circle/CircleOverview.vue';
import LearningContent from '@/components/circle/LearningContent.vue';
export default {
components: { LearningSequence, MainNavigationBar },
props: ['circleSlug',],
data() {
return {
count: 0,
circleData: {},
learningSequences: [],
completionData: {},
}
},
methods: {
toggleLearningContentCheckbox(learningContent) {
log.debug('toggleLearningContentCheckbox', learningContent);
console.log(learningContent);
import {onMounted} from 'vue'
import {useCircleStore} from '@/stores/circle';
import SelfEvaluation from '@/components/circle/SelfEvaluation.vue';
itPost('/api/completion/complete_learning_content/', {
learning_content_key: learningContent.translation_key,
}).then((data) => {
this.completionData = data;
});
},
createLearningSequences(circleData) {
// aggregate wagtail data into LearningSequence > LearningUnit > LearningPackage hierarchy
let learningSequence = null;
let learningUnit = null;
circleData.children.forEach((child) => {
// FIXME add error detection if the data does not conform to expectations
if(child.type === 'learnpath.LearningSequence') {
if(learningSequence) {
if(learningUnit) {
learningSequence.learningUnits.push(learningUnit);
}
this.learningSequences.push(learningSequence);
}
learningSequence = Object.assign(child, { learningUnits: [] });
learningUnit = { id: null, title: '', learningContents: [] };
} else if(child.type === 'learnpath.LearningUnit') {
if(learningUnit && learningUnit.learningContents.length) {
learningSequence.learningUnits.push(learningUnit);
}
learningUnit = Object.assign(child, { learningContents: [] });
} else {
learningUnit.learningContents.push(child);
}
});
const props = defineProps<{
circleSlug: string
}>()
if(learningUnit) {
learningSequence.learningUnits.push(learningUnit);
}
this.learningSequences.push(learningSequence);
const circleStore = useCircleStore();
// sum minutes
this.learningSequences.forEach((learningSequence) => {
learningSequence.minutes = 0;
learningSequence.learningUnits.forEach((learningUnit) => {
learningUnit.minutes = 0;
learningUnit.learningContents.forEach((learningContent) => {
learningUnit.minutes += learningContent.minutes;
});
learningSequence.minutes += learningUnit.minutes;
});
});
onMounted(async () => {
log.info('CircleView.vue mounted');
await circleStore.loadCircle(props.circleSlug);
});
log.debug(this.learningSequences);
},
},
mounted() {
log.debug('CircleView mounted', this.circleSlug);
itGet(`/learnpath/api/circle/${this.circleSlug}/`).then((data) => {
this.circleData = data;
this.createLearningSequences(data);
itGet(`/api/completion/circle/${this.circleData.translation_key}/`).then((completionData) => {
this.completionData = completionData;
});
});
}
}
</script>
<template>
<MainNavigationBar/>
<Transition>
<div v-if="circleStore.page === 'OVERVIEW'">
<CircleOverview :circle-data="circleStore.circleData" @close="circleStore.page = 'INDEX'"/>
</div>
<div v-else-if="circleStore.page === 'LEARNING_CONTENT'">
<LearningContent :key="circleStore.currentLearningContent.translation_key"/>
</div>
<div v-else-if="circleStore.page === 'SELF_EVALUATION'">
<SelfEvaluation :key="circleStore.currentSelfEvaluation.translation_key"/>
</div>
<div v-else>
<MainNavigationBar/>
<div class="circle">
<div class="flex flex-col lg:flex-row">
<div class="flex-initial lg:w-128 px-4 py-8 lg:px-8">
<h1 class="text-blue-dark text-7xl">
{{ circleData.title }}
</h1>
<div class="circle">
<div class="flex flex-col lg:flex-row">
<div class="flex-initial lg:w-128 px-4 py-4 lg:px-8 lg:py-8">
<h1 class="text-blue-dark text-7xl">
{{ circleStore.circleData.title }}
</h1>
<div class="mt-8">
<img src="@/assets/circle-analyse.svg" alt="">
</div>
<div class="mt-8">
<img src="@/assets/circle-analyse.svg" alt="">
</div>
<div class="outcome border border-gray-500 mt-8 p-6">
<h3 class="text-blue-dark">Das lernst du in diesem Circle.</h3>
<div class="prose mt-4">
{{ circleData.description }}
<div class="border-t-2 border-gray-500 mt-4 lg:hidden">
<div
class="mt-4 inline-flex items-center"
@click="circleStore.page = 'OVERVIEW'"
>
<it-icon-info class="mr-2"/>
Das lernst du in diesem Circle
</div>
<div class="inline-flex items-center">
<it-icon-message class="mr-2"/>
Fachexpertin kontaktieren
</div>
</div>
<div class="hidden lg:block">
<div class="block border border-gray-500 mt-8 p-6">
<h3 class="text-blue-dark">Das lernst du in diesem Circle.</h3>
<div class="prose mt-4">
{{ circleStore.circleData.description }}
</div>
<button class="btn-primary mt-4" @click="circleStore.page = 'OVERVIEW'">Erfahre mehr dazu</button>
</div>
<div class="expert border border-gray-500 mt-8 p-6">
<h3 class="text-blue-dark">Hast du Fragen?</h3>
<div class="prose mt-4">Tausche dich mit der Fachexpertin aus für den Circle Analyse aus.</div>
<button class="btn-secondary mt-4">
Fachexpertin kontaktieren
</button>
</div>
</div>
</div>
<button class="btn-primary mt-4">Erfahre mehr dazu</button>
</div>
<div class="flex-auto bg-gray-100 px-4 py-8 lg:px-24">
<div
v-for="learningSequence in circleStore.circleData.learningSequences"
:key="learningSequence.translation_key"
>
<LearningSequence
:learning-sequence="learningSequence"
:completion-data="circleStore.completionData"
></LearningSequence>
</div>
<div class="expert border border-gray-500 mt-8 p-6">
<h3 class="text-blue-dark">Hast du Fragen?</h3>
<div class="prose mt-4">Tausche dich mit der Fachexpertin aus für den Circle Analyse aus.</div>
<button class="btn-secondary mt-4">
Fachexpertin kontaktieren
</button>
</div>
</div>
<div class="flex-auto bg-gray-100 px-4 py-8 lg:px-24">
<div v-for="learningSequence in learningSequences">
<LearningSequence
:learning-sequence="learningSequence" @toggleLearningContentCheckbox="toggleLearningContentCheckbox"
:completion-data="completionData"
></LearningSequence>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.v-enter-active,
.v-leave-active {
transition: opacity 0.3s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
.v-enter-active {
transition-delay: 0.3s;
}
</style>

View File

@ -10,7 +10,7 @@ import MainNavigationBar from '@/components/MainNavigationBar.vue';</script>
<div class="mt-8 flex flex-col lg:flex-row justify-start gap-4">
<router-link class="link text-xl" to="/styleguide">Styelguide</router-link>
<a class="link text-xl" href="/login/">Login</a>
<router-link class="link text-xl" to="/learningpath/versicherungsvermittlerin">Lernpfad "Versicherungsvermittlerin" (Login benötigt)</router-link>
<!-- <router-link class="link text-xl" to="/learningpath/versicherungsvermittlerin">Lernpfad "Versicherungsvermittlerin" (Login benötigt)</router-link>-->
<router-link class="link text-xl" to="/circle/analyse">Circle "Analyse" (Login benötigt)</router-link>
</div>
</main>

View File

@ -49,106 +49,106 @@ function colorBgClass(color: string, value: number) {
<div class="mt-8 mb-8 flex flex-col gap-4 flex-wrap lg:flex-row">
<div class="inline-flex flex-col">
message
<it-icon-message class="w-8 h-8" />
<it-icon-message/>
</div>
<div class="inline-flex flex-col">
arrow-up
<it-icon-arrow-up class="w-8 h-8" />
<it-icon-arrow-up/>
</div>
<div class="inline-flex flex-col">
arrow-down
<it-icon-arrow-down class="w-8 h-8" />
<it-icon-arrow-down/>
</div>
<div class="inline-flex flex-col">
arrow-left
<it-icon-arrow-left class="w-8 h-8" />
<it-icon-arrow-left/>
</div>
<div class="inline-flex flex-col">
arrow-right
<it-icon-arrow-right class="w-8 h-8" />
<it-icon-arrow-right/>
</div>
<div class="inline-flex flex-col">
close
<it-icon-close class="w-8 h-8" />
<it-icon-close/>
</div>
<div class="inline-flex flex-col">
check
<it-icon-check class="w-8 h-8" />
<it-icon-check/>
</div>
<div class="inline-flex flex-col">
info
<it-icon-info class="w-8 h-8" />
<it-icon-info/>
</div>
<div class="inline-flex flex-col">
list
<it-icon-list class="w-8 h-8" />
<it-icon-list/>
</div>
<div class="inline-flex flex-col">
menu
<it-icon-menu class="w-8 h-8" />
<it-icon-menu/>
</div>
</div>
<div class="mt-8 mb-8 flex flex-col gap-4 flex-wrap lg:flex-row">
<div class="inline-flex flex-col">
ls-apply
<it-icon-ls-apply class="w-8 h-8" />
<it-icon-ls-apply/>
</div>
<div class="inline-flex flex-col">
ls-watch
<it-icon-ls-watch class="w-8 h-8" />
<it-icon-ls-watch/>
</div>
<div class="inline-flex flex-col">
ls-test
<it-icon-ls-test class="w-8 h-8" />
<it-icon-ls-test/>
</div>
<div class="inline-flex flex-col">
ls-practice
<it-icon-ls-practice class="w-8 h-8" />
<it-icon-ls-practice/>
</div>
<div class="inline-flex flex-col">
ls-network
<it-icon-ls-network class="w-8 h-8" />
<it-icon-ls-network/>
</div>
<div class="inline-flex flex-col">
ls-start
<it-icon-ls-start class="w-8 h-8" />
<it-icon-ls-start/>
</div>
<div class="inline-flex flex-col">
ls-end
<it-icon-ls-end class="w-8 h-8" />
<it-icon-ls-end/>
</div>
</div>
<div class="mt-8 mb-8 flex flex-col gap-4 flex-wrap lg:flex-row">
<div class="inline-flex flex-col">
smiley-happy
<it-icon-smiley-happy class="w-8 h-8" />
<it-icon-smiley-happy/>
</div>
<div class="inline-flex flex-col">
smiley-thinking
<it-icon-smiley-thinking class="w-8 h-8" />
<it-icon-smiley-thinking/>
</div>
<div class="inline-flex flex-col">
smiley-neutral
<it-icon-smiley-neutral class="w-8 h-8" />
<it-icon-smiley-neutral/>
</div>
</div>
@ -214,6 +214,7 @@ function colorBgClass(color: string, value: number) {
<button class="btn-primary">Primary</button>
<button class="btn-secondary">Secondary</button>
<button class="btn-blue">Blue</button>
<button class="btn-text">Text</button>
<a class="btn-primary inline-block" href="/">Primary Link</a>
</div>
@ -223,9 +224,7 @@ function colorBgClass(color: string, value: number) {
<button disabled class="btn-blue">Blue disabled</button>
</div>
<div class="flex flex-col gap-4 flex-wrap lg:flex-row lg:w-128 content-center lg:justify-start mb-16">
<div class="flex flex-col gap-4 flex-wrap lg:flex-row content-center lg:justify-start mb-16">
<button type="button"
class="btn-primary inline-flex items-center p-3 rounded-full">
<it-icon-message class="h-5 w-5"></it-icon-message>
@ -242,6 +241,13 @@ function colorBgClass(color: string, value: number) {
Button text
<it-icon-message class="ml-3 -mr-1 h-5 w-5"></it-icon-message>
</button>
<button type="button"
class="btn-text inline-flex items-center px-3 py-2">
<it-icon-message class="-ml-1 mr-3 h-5 w-5"></it-icon-message>
Button text
</button>
</div>
<h2 class="mt-8 mb-8">Dropdown (Work-in-progress)</h2>

View File

@ -4,6 +4,7 @@
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]

View File

@ -4,6 +4,10 @@
"compilerOptions": {
"composite": true,
"lib": [],
"types": ["node", "jsdom"]
"types": [
"node",
"jsdom",
"vitest/globals"
]
}
}

View File

@ -6,17 +6,17 @@ import vue from '@vitejs/plugin-vue'
import alias from '@rollup/plugin-alias'
// https://vitejs.dev/config/
export default defineConfig(({mode}) => {
process.env = {...process.env, ...loadEnv(mode, process.cwd())};
export default defineConfig(({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }
return {
plugins: [
vue({
template: {
compilerOptions: {
// treat all tags which start with '<it-' as custom elements
isCustomElement: (tag) => tag.startsWith('it-')
}
}
isCustomElement: (tag) => tag.startsWith('it-'),
},
},
}),
// vueI18n({
// include: path.resolve(__dirname, './locales/**')
@ -40,6 +40,10 @@ export default defineConfig(({mode}) => {
},
build: {
assetsDir: 'static/vue',
}
},
test: {
globals: true,
environment: 'happy-dom',
},
}
})

View File

@ -4,8 +4,15 @@ set -o errexit
set -o pipefail
set -o nounset
# TODO remove after stabilisation
python /app/manage.py reset_schema
python /app/manage.py collectstatic --noinput
python /app/manage.py createcachetable
python /app/manage.py migrate
# TODO remove after stabilisation
python /app/manage.py create_default_users
python /app/manage.py create_default_learning_path
/usr/local/bin/gunicorn config.asgi --bind 0.0.0.0:80 --chdir=/app -k uvicorn.workers.UvicornWorker

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2022-06-08 13:52
# Generated by Django 3.2.13 on 2022-06-22 16:53
from django.conf import settings
from django.db import migrations, models
@ -15,52 +15,21 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name='UserCircleCompletion',
name='CircleCompletion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('page_key', models.UUIDField()),
('page_type', models.CharField(blank=True, default='', max_length=255)),
('circle_key', models.UUIDField()),
('json_data', models.JSONField(blank=True, default=dict)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='LearningUnitQuestionCompletion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('question_key', models.UUIDField()),
('circle_key', models.UUIDField()),
('completed', models.BooleanField(default=True)),
('json_data', models.JSONField(blank=True, default=dict)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='LearningContentCompletion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('learning_content_key', models.UUIDField()),
('circle_key', models.UUIDField()),
('completed', models.BooleanField(default=True)),
('completed', models.BooleanField(default=False)),
('json_data', models.JSONField(blank=True, default=dict)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='usercirclecompletion',
constraint=models.UniqueConstraint(fields=('user', 'circle_key'), name='unique_user_circle_completion'),
),
migrations.AddConstraint(
model_name='learningunitquestioncompletion',
constraint=models.UniqueConstraint(fields=('user', 'question_key'), name='unique_user_question_key'),
),
migrations.AddConstraint(
model_name='learningcontentcompletion',
constraint=models.UniqueConstraint(fields=('user', 'learning_content_key'), name='unique_user_learning_content_key'),
model_name='circlecompletion',
constraint=models.UniqueConstraint(fields=('user', 'page_key'), name='unique_user_page_key'),
),
]

View File

@ -4,56 +4,25 @@ from django.db.models import UniqueConstraint
from vbv_lernwelt.core.models import User
class LearningContentCompletion(models.Model):
class CircleCompletion(models.Model):
# Page can either be a LearningContent or a LearningUnitQuestion for now
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
learning_content_key = models.UUIDField()
# Page can either be a LearningContent or a LearningUnitQuestion for now
page_key = models.UUIDField()
page_type = models.CharField(max_length=255, default='', blank=True)
circle_key = models.UUIDField()
completed = models.BooleanField(default=True)
completed = models.BooleanField(default=False)
json_data = models.JSONField(default=dict, blank=True)
class Meta:
constraints = [
UniqueConstraint(
fields=['user', 'learning_content_key', ],
name='unique_user_learning_content_key'
fields=['user', 'page_key', ],
name='unique_user_page_key'
)
]
class LearningUnitQuestionCompletion(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
question_key = models.UUIDField()
circle_key = models.UUIDField()
completed = models.BooleanField(default=True)
json_data = models.JSONField(default=dict, blank=True)
class Meta:
constraints = [
UniqueConstraint(
fields=['user', 'question_key', ],
name='unique_user_question_key'
)
]
class UserCircleCompletion(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
circle_key = models.UUIDField()
json_data = models.JSONField(default=dict, blank=True)
class Meta:
constraints = [
UniqueConstraint(fields=['user', 'circle_key'], name='unique_user_circle_completion')
]

View File

@ -1,15 +1,13 @@
from rest_framework import serializers
from vbv_lernwelt.completion.models import UserCircleCompletion, LearningContentCompletion
from vbv_lernwelt.completion.models import CircleCompletion
class UserCircleCompletionSerializer(serializers.ModelSerializer):
class CircleCompletionSerializer(serializers.ModelSerializer):
class Meta:
model = UserCircleCompletion
fields = ['id', 'created_at', 'updated_at', 'user', 'circle_key', 'json_data']
model = CircleCompletion
fields = [
'id', 'created_at', 'updated_at', 'user', 'page_key', 'page_type', 'circle_key',
'completed', 'json_data',
]
class LearningContentCompletionSerializer(serializers.ModelSerializer):
class Meta:
model = LearningContentCompletion
fields = ['id', 'created_at', 'updated_at', 'user', 'learning_content_key', 'circle_key', 'json_data']

View File

@ -26,30 +26,28 @@ class CompletionApiTestCase(APITestCase):
learning_content_key = str(learning_content.translation_key)
circle_key = str(learning_content.get_parent().translation_key)
response = self.client.post(f'/api/completion/complete_learning_content/', {
'learning_content_key': learning_content_key
mark_url = f'/api/completion/circle/mark/'
print(mark_url)
response = self.client.post(mark_url, {
'page_key': learning_content_key,
})
response_json = response.json()
print(json.dumps(response.json(), indent=2))
self.assertEqual(response.status_code, 200)
self.assertEqual(response_json['circle_key'], circle_key)
self.assertEqual(
response_json['json_data']['completed_learning_contents'][learning_content_key]['learning_content_key'],
learning_content_key
)
self.assertEqual(len(response_json), 1)
self.assertEqual(response_json[0]['page_key'], learning_content_key)
self.assertEqual(response_json[0]['circle_key'], circle_key)
self.assertTrue(response_json[0]['completed'])
# test getting the circle data
response = self.client.get(f'/api/completion/user_circle_completion/{circle_key}/')
response = self.client.get(f'/api/completion/circle/{circle_key}/')
response_json = response.json()
print(json.dumps(response.json(), indent=2))
self.assertEqual(response.status_code, 200)
self.assertEqual(response_json['circle_key'], circle_key)
self.assertEqual(
response_json['json_data']['completed_learning_contents'][learning_content_key]['learning_content_key'],
learning_content_key
)
self.assertEqual(len(response_json), 1)
self.assertEqual(response_json[0]['page_key'], learning_content_key)
self.assertEqual(response_json[0]['circle_key'], circle_key)
self.assertTrue(response_json[0]['completed'])

View File

@ -1,8 +1,8 @@
from django.urls import path
from vbv_lernwelt.completion.views import complete_learning_content, request_user_circle_completion
from vbv_lernwelt.completion.views import request_circle_completion, mark_circle_completion
urlpatterns = [
path(r"circle/<uuid:circle_key>/", request_user_circle_completion, name="request_user_circle_completion"),
path(r"complete_learning_content/", complete_learning_content, name="complete_learning_content"),
path(r"circle/<uuid:circle_key>/", request_circle_completion, name="request_circle_completion"),
path(r"circle/mark/", mark_circle_completion, name="mark_circle_completion"),
]

View File

@ -1,54 +1,58 @@
from datetime import datetime
import structlog
from rest_framework.decorators import api_view
from rest_framework.response import Response
from wagtail.models import Page
from vbv_lernwelt.completion.models import LearningContentCompletion, UserCircleCompletion
from vbv_lernwelt.completion.serializers import UserCircleCompletionSerializer
from vbv_lernwelt.learnpath.models import LearningContent
from vbv_lernwelt.completion.models import CircleCompletion
from vbv_lernwelt.completion.serializers import CircleCompletionSerializer
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.learnpath.utils import get_wagtail_type
logger = structlog.get_logger(__name__)
@api_view(['GET'])
def request_user_circle_completion(request, circle_key):
ucc = UserCircleCompletion.objects.filter(
user=request.user,
circle_key=circle_key,
)
def request_circle_completion(request, circle_key):
response_data = CircleCompletionSerializer(
CircleCompletion.objects.filter(user=request.user, circle_key=circle_key),
many=True,
).data
if ucc.count() > 0:
return Response(status=200, data=UserCircleCompletionSerializer(ucc.first()).data)
else:
return Response(status=200, data={})
return Response(status=200, data=response_data)
@api_view(['POST'])
def complete_learning_content(request):
learning_content_key = request.data.get('learning_content_key')
learning_content = LearningContent.objects.get(translation_key=learning_content_key)
def mark_circle_completion(request):
page_key = request.data.get('page_key')
completed = request.data.get('completed', True)
circle_key = learning_content.get_parent().translation_key
page = Page.objects.get(translation_key=page_key)
page_type = get_wagtail_type(page.specific)
circle = Circle.objects.ancestor_of(page).first()
LearningContentCompletion.objects.get_or_create(
cc, created = CircleCompletion.objects.get_or_create(
user=request.user,
learning_content_key=learning_content_key,
circle_key=circle_key,
page_key=page_key,
circle_key=circle.translation_key,
)
cc.page_type = page_type
cc.completed = completed
cc.save()
ucc, created = UserCircleCompletion.objects.get_or_create(
user=request.user,
circle_key=circle_key,
)
ucc.save()
response_data = CircleCompletionSerializer(
CircleCompletion.objects.filter(user=request.user, circle_key=circle.translation_key),
many=True,
).data
logger.debug(
'learning content completed',
'page completed',
label='completion_api',
circle_key=circle_key,
learning_content_key=learning_content_key,
circle_key=circle.translation_key,
circle_title=circle.title,
page_key=page_key,
page_type=page_type,
page_title=page.title,
user_id=request.user.id,
)
return Response(status=200, data=UserCircleCompletionSerializer(ucc).data)
return Response(status=200, data=response_data)

View File

@ -0,0 +1,26 @@
import djclick as click
from django.conf import settings
from django.db import transaction, connection
def reset_schema(db_config_user):
sql_list = (
'DROP SCHEMA public CASCADE',
f'CREATE SCHEMA public AUTHORIZATION {db_config_user}',
'GRANT ALL ON SCHEMA public TO postgres',
'GRANT ALL ON SCHEMA public TO public',
"COMMENT ON SCHEMA public IS 'standard public schema';",
)
with transaction.atomic():
with connection.cursor() as cursor:
for sql in sql_list:
cursor.execute(sql)
@click.command()
def command():
user = settings.DATABASES['default']['USER']
print(user)
reset_schema(db_config_user=user)

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2022-06-14 08:51
# Generated by Django 3.2.13 on 2022-06-22 15:48
from django.db import migrations, models
import django.db.models.deletion
@ -61,7 +61,7 @@ class Migration(migrations.Migration):
('contents', wagtail.fields.StreamField([('video', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('web_based_training', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('podcast', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('competence', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())])), ('exercise', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())])), ('document', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())])), ('knowledge', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())]))], use_json_field=None)),
],
options={
'verbose_name': 'Learning Unit',
'verbose_name': 'Learning Content',
},
bases=('wagtailcore.page',),
),
@ -90,13 +90,22 @@ class Migration(migrations.Migration):
name='LearningUnit',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('questions', wagtail.fields.StreamField([('question', wagtail.blocks.CharBlock())], use_json_field=True)),
],
options={
'verbose_name': 'Learning Unit',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='LearningUnitQuestion',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'verbose_name': 'Learning Unit Question',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='Topic',
fields=[

View File

@ -85,7 +85,7 @@ class Circle(Page):
], use_json_field=True)
parent_page_types = ['learnpath.LearningPath']
subpage_types = ['learnpath.LearningSequence', 'learnpath.LearningUnit']
subpage_types = ['learnpath.LearningSequence', 'learnpath.LearningUnit', 'learnpath.LearningContent']
content_panels = Page.content_panels + [
FieldPanel('description'),
@ -100,7 +100,13 @@ class Circle(Page):
@classmethod
def get_serializer_class(cls):
return get_it_serializer_class(cls, field_names=['id', 'title', 'slug', 'type', 'translation_key', 'learning_sequences'])
return get_it_serializer_class(
cls,
field_names=[
'id', 'title', 'slug', 'type', 'translation_key', 'learning_sequences', 'children',
'description', 'job_situations', 'goals', 'experts',
]
)
def full_clean(self, *args, **kwargs):
self.slug = find_available_slug(Circle, slugify(self.title, allow_unicode=True))
@ -144,14 +150,6 @@ class LearningUnit(Page):
parent_page_types = ['learnpath.Circle']
subpage_types = []
questions = StreamField([
('question', blocks.CharBlock()),
], use_json_field=True)
content_panels = Page.content_panels + [
FieldPanel('questions'),
]
class Meta:
verbose_name = "Learning Unit"
@ -160,7 +158,22 @@ class LearningUnit(Page):
@classmethod
def get_serializer_class(cls):
return get_it_serializer_class(cls, field_names=['id', 'title', 'slug', 'type', 'translation_key', 'questions'])
return get_it_serializer_class(cls, field_names=['id', 'title', 'slug', 'type', 'translation_key', 'children'])
class LearningUnitQuestion(Page):
parent_page_types = ['learnpath.LearningUnit']
subpage_types = []
class Meta:
verbose_name = "Learning Unit Question"
def __str__(self):
return f"{self.title}"
@classmethod
def get_serializer_class(cls):
return get_it_serializer_class(cls, field_names=['id', 'title', 'slug', 'type', 'translation_key', ])
class LearningContent(Page):
@ -200,7 +213,7 @@ class LearningContent(Page):
return display_title
class Meta:
verbose_name = "Learning Unit"
verbose_name = "Learning Content"
def full_clean(self, *args, **kwargs):
self.slug = find_available_slug(LearningContent, slugify(self.title, allow_unicode=True))

View File

@ -1,4 +1,7 @@
import wagtail.api.v2.serializers as wagtail_serializers
from rest_framework.fields import SerializerMethodField
from vbv_lernwelt.learnpath.utils import get_wagtail_type
def get_it_serializer_class(model, field_names):
@ -7,9 +10,15 @@ def get_it_serializer_class(model, field_names):
class ItTypeField(wagtail_serializers.TypeField):
def to_representation(self, obj):
name = type(obj)._meta.app_label + '.' + type(obj).__name__
name = get_wagtail_type(obj)
return name
class ItBaseSerializer(wagtail_serializers.BaseSerializer):
type = ItTypeField(read_only=True)
children = SerializerMethodField()
meta_fields = []
def get_children(self, obj):
return [c.specific.get_serializer_class()(c.specific).data for c in obj.get_children()]

View File

@ -4,25 +4,6 @@ from vbv_lernwelt.learnpath.models import Circle, LearningPath
from vbv_lernwelt.learnpath.serializer_helpers import get_it_serializer_class
class CircleSerializer(get_it_serializer_class(Circle, [])):
children = serializers.SerializerMethodField()
meta_fields = []
def get_children(self, obj):
return [c.specific.get_serializer_class()(c.specific).data for c in obj.get_children()]
def get_meta_label(self, obj):
return obj._meta.label
class Meta:
model = Circle
fields = [
'id', 'title', 'slug', 'type', 'translation_key',
'children', 'description', 'job_situations', 'goals', 'experts',
]
class LearningPathSerializer(get_it_serializer_class(LearningPath, [])):
children = serializers.SerializerMethodField()

View File

@ -6,7 +6,7 @@ from vbv_lernwelt.core.admin import User
from vbv_lernwelt.learnpath.models import LearningPath, Topic, Circle, LearningSequence, LearningContent
from vbv_lernwelt.learnpath.tests.learning_path_factories import LearningPathFactory, TopicFactory, CircleFactory, \
LearningSequenceFactory, LearningContentFactory, VideoBlockFactory, PodcastBlockFactory, CompetenceBlockFactory, \
ExerciseBlockFactory, DocumentBlockFactory, LearningUnitFactory
ExerciseBlockFactory, DocumentBlockFactory, LearningUnitFactory, LearningUnitQuestionFactory
def create_default_learning_path(user=None):
@ -98,16 +98,34 @@ Fachspezialisten bei.
title='Einleitung Circle "Anlayse"',
parent=circe_analyse,
minutes=15,
contents=[('video', VideoBlockFactory())]
contents=[('video', VideoBlockFactory(
url='https://www.youtube.com/embed/qhPIfxS2hvI',
description='In dieser Circle zeigt dir ein Fachexperte anhand von Kundensituationen, wie du erfolgreich'
'den Kundenbedarf ermitteln, analysieren, priorisieren und anschliessend zusammenfassen kannst.'
))]
)
LearningSequenceFactory(title='Beobachten', parent=circe_analyse, icon='it-icon-ls-watch')
LearningUnitFactory(title='Abischerung der Familie', parent=circe_analyse)
lu = LearningUnitFactory(
title='Absicherung der Familie',
parent=circe_analyse,
)
LearningUnitQuestionFactory(
title="Ich bin in der Lage, mit geeigneten Fragestellungen die Deckung von Versicherungen zu erfassen.",
parent=lu
)
LearningUnitQuestionFactory(
title="Zweite passende Frage zu 'Absicherung der Familie'",
parent=lu
)
LearningContentFactory(
title='Ermittlung des Kundenbedarfs',
parent=circe_analyse,
minutes=30,
contents=[('podcast', PodcastBlockFactory())]
contents=[('podcast', PodcastBlockFactory(
description='Die Ermittlung des Kundenbedarfs muss in einem eingehenden Gespräch herausgefunden werden. Höre dazu auch diesen Podcast an.',
url='https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/325190984&color=%23ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true',
))]
)
LearningContentFactory(
title='Kundenbedürfnisse erkennen',
@ -123,7 +141,11 @@ Fachspezialisten bei.
)
LearningSequenceFactory(title='Anwenden', parent=circe_analyse, icon='it-icon-ls-apply')
LearningUnitFactory(title='Prämien einsparen', parent=circe_analyse)
lu = LearningUnitFactory(title='Prämien einsparen', parent=circe_analyse)
LearningUnitQuestionFactory(
title="Passende Frage zu Anwenden",
parent=lu
)
LearningContentFactory(
title='Versicherungsbedarf für Familien',
parent=circe_analyse,
@ -137,7 +159,11 @@ Fachspezialisten bei.
contents=[('exercise', ExerciseBlockFactory())]
)
LearningUnitFactory(title='Sich selbständig machen', parent=circe_analyse)
lu = LearningUnitFactory(title='Sich selbständig machen', parent=circe_analyse)
LearningUnitQuestionFactory(
title="Passende Frage zu 'Sich selbständig machen'",
parent=lu
)
LearningContentFactory(
title='GmbH oder AG',
parent=circe_analyse,
@ -151,7 +177,11 @@ Fachspezialisten bei.
contents=[('exercise', ExerciseBlockFactory())]
)
LearningUnitFactory(title='Auto verkaufen', parent=circe_analyse)
lu = LearningUnitFactory(title='Auto verkaufen', parent=circe_analyse)
LearningUnitQuestionFactory(
title='Passende Frage zu "Auto verkaufen"',
parent=lu
)
LearningContentFactory(
title='Motorfahrzeugversicherung',
parent=circe_analyse,
@ -177,8 +207,84 @@ Fachspezialisten bei.
contents=[('exercise', ExerciseBlockFactory())]
)
lu = LearningUnitFactory(title='Pensionierung', parent=circe_analyse)
LearningUnitQuestionFactory(
title='Passende Frage zu "Pensionierung"',
parent=lu
)
LearningContentFactory(
title='3-Säulen-Prinzip',
parent=circe_analyse,
minutes=240,
contents=[('competence', CompetenceBlockFactory())]
)
LearningContentFactory(
title='Altersvorsorge',
parent=circe_analyse,
minutes=240,
contents=[('competence', CompetenceBlockFactory())]
)
LearningContentFactory(
title='AHV',
parent=circe_analyse,
minutes=120,
contents=[('document', DocumentBlockFactory())]
)
LearningContentFactory(
title='Altersvorsorge planen',
parent=circe_analyse,
minutes=120,
contents=[('exercise', ExerciseBlockFactory())]
)
lu = LearningUnitFactory(title='Reisen', parent=circe_analyse)
LearningUnitQuestionFactory(
title='Passende Frage zu "Reisen"',
parent=lu
)
LearningContentFactory(
title='Reiseversicherung',
parent=circe_analyse,
minutes=240,
contents=[('competence', CompetenceBlockFactory())]
)
LearningContentFactory(
title='Sorgenfrei reisen',
parent=circe_analyse,
minutes=120,
contents=[('exercise', ExerciseBlockFactory())]
)
lu = LearningUnitFactory(title='Haushalt', parent=circe_analyse)
LearningUnitQuestionFactory(
title='Passende Frage zu "Haushalt"',
parent=lu
)
LearningContentFactory(
title='Privathaftpflicht',
parent=circe_analyse,
minutes=240,
contents=[('competence', CompetenceBlockFactory())]
)
LearningContentFactory(
title='Zusatzversicherung',
parent=circe_analyse,
minutes=120,
contents=[('document', DocumentBlockFactory())]
)
LearningContentFactory(
title='Einen eigenen Haushalt führen',
parent=circe_analyse,
minutes=120,
contents=[('exercise', ExerciseBlockFactory())]
)
LearningSequenceFactory(title='Üben', parent=circe_analyse, icon='it-icon-ls-practice')
LearningUnitFactory(title='Kind zieht von zu Hause aus', parent=circe_analyse)
lu = LearningUnitFactory(title='Kind zieht von zu Hause aus', parent=circe_analyse)
LearningUnitQuestionFactory(
title='Passende Frage zu "Kind zieht von zu Hause aus"',
parent=lu
)
LearningContentFactory(
title='Hausrat',
parent=circe_analyse,
@ -198,6 +304,43 @@ Fachspezialisten bei.
contents=[('competence', CompetenceBlockFactory())]
)
LearningSequenceFactory(title='Testen', parent=circe_analyse, icon='it-icon-ls-test')
lu = LearningUnitFactory(title='Kind zieht von zu Hause aus "Testen"', parent=circe_analyse)
LearningContentFactory(
title='Das erwartet dich im Test',
parent=circe_analyse,
minutes=30,
contents=[('document', CompetenceBlockFactory())]
)
LearningContentFactory(
title='Test durchführen',
parent=circe_analyse,
minutes=30,
contents=[('document', CompetenceBlockFactory())]
)
LearningSequenceFactory(title='Vernetzen', parent=circe_analyse, icon='it-icon-ls-network')
LearningContentFactory(
title='Online Training',
parent=circe_analyse,
minutes=60,
contents=[('document', CompetenceBlockFactory())]
)
LearningSequenceFactory(title='Beenden', parent=circe_analyse, icon='it-icon-ls-end')
LearningContentFactory(
title='Kompetenzprofil anschauen',
parent=circe_analyse,
minutes=30,
contents=[('document', CompetenceBlockFactory())]
)
LearningContentFactory(
title='Circle "Analyse" abschliessen',
parent=circe_analyse,
minutes=30,
contents=[('document', CompetenceBlockFactory())]
)
# learning_unit = LearningUnitFactory.create(title='** Einstieg Video"', parent=circle_4)
# video_url = "https://www.vbv.ch/fileadmin/vbv/Videos/Statements_Externe/Janos_M/Testimonial_Janos_Mischler_PositiveEffekte.mp4"
# video_title = "Ausbildung ist pflicht"

View File

@ -1,7 +1,7 @@
import factory
import wagtail_factories
from vbv_lernwelt.learnpath.models import LearningPath, Topic, Circle, LearningSequence, LearningContent, LearningUnit
from vbv_lernwelt.learnpath.models import LearningPath, Topic, Circle, LearningSequence, LearningContent, LearningUnit, \
LearningUnitQuestion
from vbv_lernwelt.learnpath.models_learning_unit_content import VideoBlock, WebBasedTrainingBlock, PodcastBlock, \
CompetenceBlock, ExerciseBlock, DocumentBlock, KnowledgeBlock
@ -42,8 +42,15 @@ class LearningUnitFactory(wagtail_factories.PageFactory):
model = LearningUnit
class LearningUnitQuestionFactory(wagtail_factories.PageFactory):
title = 'Frage zu Lerneinheit'
class Meta:
model = LearningUnitQuestion
class LearningContentFactory(wagtail_factories.PageFactory):
title = "Herzlich Willkommen"
title = 'Lerninhalt'
class Meta:
model = LearningContent

View File

@ -0,0 +1,2 @@
def get_wagtail_type(obj):
return obj._meta.app_label + '.' + type(obj).__name__

View File

@ -8,16 +8,14 @@ from rest_framework.decorators import api_view
from rest_framework.response import Response
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.learnpath.serializers import CircleSerializer
from vbv_lernwelt.learnpath.models import Circle, LearningPath
from vbv_lernwelt.learnpath.serializers import CircleSerializer, LearningPathSerializer
from vbv_lernwelt.learnpath.serializers import LearningPathSerializer
@api_view(['GET'])
def circle_view(request, slug):
circle = Circle.objects.get(slug=slug)
serializer = CircleSerializer(circle)
serializer = Circle.get_serializer_class()(circle)
return Response(serializer.data)

View File

@ -4,7 +4,7 @@ example:
class icon_arrow_up extends HTMLElement {
connectedCallback() {
this.innerHTML = `<svg width="30" height="30" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
this.innerHTML = `<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M14.9383 10L23.7001 18.9614C23.916 19.2016 23.9084 19.5731 23.6828 19.8038C23.4573 20.0344 23.0941 20.0422 22.8593 19.8214L14.9383 11.72L7.01742 19.8214C6.86685 19.9754 6.6474 20.0356 6.44173 19.9792C6.23605 19.9228 6.0754 19.7585 6.02029 19.5482C5.96518 19.3378 6.02398 19.1134 6.17455 18.9594L14.9383 10Z"
/>
</svg>
@ -20,7 +20,10 @@ customElements.define('it-icon-arrow-up', icon_arrow_up);
class {{ svg_icon.classname }} extends HTMLElement {
connectedCallback() {
this.innerHTML = `{{ svg_icon.content|safe }}`;
this.classList.add('it-icon');
this.innerHTML = `
{{ svg_icon.content|safe }}
`;
}
}

View File

@ -11,6 +11,9 @@ svg {
}
@layer base {
.it-icon {
@apply w-8 h-8 inline-block
}
h1 {
@apply text-4xl md:text-5xl xl:text-7xl font-bold
@ -67,5 +70,10 @@ svg {
disabled:opacity-50 disabled:cursor-not-allowed
}
.btn-text {
@apply font-bold py-2 px-4 align-middle inline-block
hover:text-gray-700
disabled:opacity-50 disabled:cursor-not-allowed
}
}