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_SECRET_KEY': env.str('IT_DJANGO_SECRET_KEY'),
'IT_DJANGO_ADMIN_URL': env.str('IT_DJANGO_ADMIN_URL'), 'IT_DJANGO_ADMIN_URL': env.str('IT_DJANGO_ADMIN_URL'),
'IT_DJANGO_ALLOWED_HOSTS': env.str('IT_DJANGO_ALLOWED_HOSTS'), 'IT_DJANGO_ALLOWED_HOSTS': env.str('IT_DJANGO_ALLOWED_HOSTS'),
'IT_DJANGO_DEBUG': 'false',
'IT_SENTRY_DSN': env.str('IT_SENTRY_DSN'), 'IT_SENTRY_DSN': env.str('IT_SENTRY_DSN'),
'IT_APP_ENVIRONMENT': 'caprover', 'IT_APP_ENVIRONMENT': 'caprover',
'POSTGRES_HOST': 'srv-captain--vbv-lernwelt-postgres-db', 'POSTGRES_HOST': 'srv-captain--vbv-lernwelt-postgres-db',

View File

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

View File

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

View File

@ -4,10 +4,8 @@
"scripts": { "scripts": {
"dev": "vite", "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/", "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": "vitest run",
"test:unit": "vitest --environment jsdom", "coverage": "vitest run --coverage",
"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'",
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "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" "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", "@intlify/vite-plugin-vue-i18n": "^3.4.0",
"@rollup/plugin-alias": "^3.1.9", "@rollup/plugin-alias": "^3.1.9",
"@rushstack/eslint-patch": "^1.1.0", "@rushstack/eslint-patch": "^1.1.0",
"@testing-library/vue": "^6.6.0",
"@types/jsdom": "^16.2.14", "@types/jsdom": "^16.2.14",
"@types/node": "^16.11.26", "@types/node": "^16.11.26",
"@vitejs/plugin-vue": "^2.3.1", "@vitejs/plugin-vue": "^2.3.1",
@ -38,7 +37,7 @@
"eslint": "^8.5.0", "eslint": "^8.5.0",
"eslint-plugin-cypress": "^2.12.1", "eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-vue": "^8.2.0", "eslint-plugin-vue": "^8.2.0",
"jsdom": "^19.0.0", "happy-dom": "^5.3.1",
"postcss": "^8.4.12", "postcss": "^8.4.12",
"postcss-import": "^14.1.0", "postcss-import": "^14.1.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
@ -47,7 +46,7 @@
"start-server-and-test": "^1.14.0", "start-server-and-test": "^1.14.0",
"typescript": "~4.6.3", "typescript": "~4.6.3",
"vite": "^2.9.1", "vite": "^2.9.1",
"vitest": "^0.8.1", "vitest": "^0.15.1",
"vue-tsc": "^0.33.9" "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' import MainNavigationBar from '../MainNavigationBar.vue'
describe('MainNavigationBar', () => { describe('MainNavigationBar', () => {
it('renders properly', () => { it('renders properly', () => {
const wrapper = mount(MainNavigationBar, { }) const wrapper = mount(MainNavigationBar, {})
expect(wrapper.text()).toContain('Ich bin ein myVBV Heade') 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"> <script setup lang="ts">
import ItCheckbox from '@/components/ui/ItCheckbox.vue'; 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) => { const circleStore = useCircleStore();
if (props.completionData?.json_data?.completed_learning_contents) {
return learningContent.translation_key in props.completionData.json_data.completed_learning_contents; 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; return false;
} })
</script> </script>
@ -25,10 +36,17 @@ const contentCompleted = (learningContent) => {
<div>{{ learningSequence.minutes }} Minuten</div> <div>{{ learningSequence.minutes }} Minuten</div>
</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 <div
v-for="learningUnit in learningSequence.learningUnits" 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="pb-3 flex gap-4" v-if="learningUnit.title">
<div class="font-bold">{{ learningUnit.title }}</div> <div class="font-bold">{{ learningUnit.title }}</div>
@ -37,17 +55,46 @@ const contentCompleted = (learningContent) => {
<div <div
v-for="learningContent in learningUnit.learningContents" v-for="learningContent in learningUnit.learningContents"
:key="learningContent.id"
class="flex items-center gap-4 pb-3" class="flex items-center gap-4 pb-3"
> >
<ItCheckbox <ItCheckbox
:modelValue="contentCompleted(learningContent)" :modelValue="learningContent.completed"
@click="$emit('toggleLearningContentCheckbox', learningContent)" @click="toggleCompleted(learningContent)"
> >
{{ learningContent.contents[0].type }}: {{ learningContent.title }} <span @click.stop="circleStore.openLearningContent(learningContent)">{{ learningContent.contents[0].type }}: {{ learningContent.title }}</span>
</ItCheckbox> </ItCheckbox>
</div> </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>
</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 { class FetchError extends Error {
constructor(response, message = 'HTTP error ' + response.status) { response: Response;
constructor(response: Response, message = 'HTTP error ' + response.status) {
super(message); super(message);
this.response = response; this.response = response;
} }
} }
export const itFetch = (url, options) => { export const itFetch = (url: RequestInfo, options: RequestInit) => {
return fetch(url, options).then(response => { return fetch(url, options).then(response => {
if (!response.ok) { if (!response.ok) {
throw new FetchError(response); 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); options = Object.assign({}, options);
const headers = Object.assign({ const headers = Object.assign({
@ -35,6 +41,8 @@ export const itPost = (url, data, options) => {
body: JSON.stringify(data) body: JSON.stringify(data)
}, options); }, options);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
options.headers['X-CSRFToken'] = getCookieValue('csrftoken'); options.headers['X-CSRFToken'] = getCookieValue('csrftoken');
if (options.method === 'GET') { 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'}); 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 * as log from 'loglevel';
import MainNavigationBar from '../components/MainNavigationBar.vue'; import MainNavigationBar from '@/components/MainNavigationBar.vue';
import LearningSequence from '../components/circle/LearningSequence.vue'; import LearningSequence from '@/components/circle/LearningSequence.vue';
import { itGet, itPost } from '../fetchHelpers'; import CircleOverview from '@/components/circle/CircleOverview.vue';
import LearningContent from '@/components/circle/LearningContent.vue';
export default { import {onMounted} from 'vue'
components: { LearningSequence, MainNavigationBar }, import {useCircleStore} from '@/stores/circle';
props: ['circleSlug',], import SelfEvaluation from '@/components/circle/SelfEvaluation.vue';
data() {
return {
count: 0,
circleData: {},
learningSequences: [],
completionData: {},
}
},
methods: {
toggleLearningContentCheckbox(learningContent) {
log.debug('toggleLearningContentCheckbox', learningContent);
console.log(learningContent);
itPost('/api/completion/complete_learning_content/', { const props = defineProps<{
learning_content_key: learningContent.translation_key, circleSlug: string
}).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);
}
});
if(learningUnit) { const circleStore = useCircleStore();
learningSequence.learningUnits.push(learningUnit);
}
this.learningSequences.push(learningSequence);
// sum minutes onMounted(async () => {
this.learningSequences.forEach((learningSequence) => { log.info('CircleView.vue mounted');
learningSequence.minutes = 0; await circleStore.loadCircle(props.circleSlug);
learningSequence.learningUnits.forEach((learningUnit) => { });
learningUnit.minutes = 0;
learningUnit.learningContents.forEach((learningContent) => {
learningUnit.minutes += learningContent.minutes;
});
learningSequence.minutes += learningUnit.minutes;
});
});
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> </script>
<template> <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="circle">
<div class="flex flex-col lg:flex-row"> <div class="flex flex-col lg:flex-row">
<div class="flex-initial lg:w-128 px-4 py-8 lg:px-8"> <div class="flex-initial lg:w-128 px-4 py-4 lg:px-8 lg:py-8">
<h1 class="text-blue-dark text-7xl"> <h1 class="text-blue-dark text-7xl">
{{ circleData.title }} {{ circleStore.circleData.title }}
</h1> </h1>
<div class="mt-8"> <div class="mt-8">
<img src="@/assets/circle-analyse.svg" alt=""> <img src="@/assets/circle-analyse.svg" alt="">
</div> </div>
<div class="outcome border border-gray-500 mt-8 p-6"> <div class="border-t-2 border-gray-500 mt-4 lg:hidden">
<h3 class="text-blue-dark">Das lernst du in diesem Circle.</h3> <div
<div class="prose mt-4"> class="mt-4 inline-flex items-center"
{{ circleData.description }} @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> </div>
<button class="btn-primary mt-4">Erfahre mehr dazu</button> <div class="flex-auto bg-gray-100 px-4 py-8 lg:px-24">
</div> <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"> </div>
<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> </div>
</Transition>
</div>
</template> </template>
<style scoped> <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> </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"> <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> <router-link class="link text-xl" to="/styleguide">Styelguide</router-link>
<a class="link text-xl" href="/login/">Login</a> <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> <router-link class="link text-xl" to="/circle/analyse">Circle "Analyse" (Login benötigt)</router-link>
</div> </div>
</main> </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="mt-8 mb-8 flex flex-col gap-4 flex-wrap lg:flex-row">
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
message message
<it-icon-message class="w-8 h-8" /> <it-icon-message/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
arrow-up arrow-up
<it-icon-arrow-up class="w-8 h-8" /> <it-icon-arrow-up/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
arrow-down arrow-down
<it-icon-arrow-down class="w-8 h-8" /> <it-icon-arrow-down/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
arrow-left arrow-left
<it-icon-arrow-left class="w-8 h-8" /> <it-icon-arrow-left/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
arrow-right arrow-right
<it-icon-arrow-right class="w-8 h-8" /> <it-icon-arrow-right/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
close close
<it-icon-close class="w-8 h-8" /> <it-icon-close/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
check check
<it-icon-check class="w-8 h-8" /> <it-icon-check/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
info info
<it-icon-info class="w-8 h-8" /> <it-icon-info/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
list list
<it-icon-list class="w-8 h-8" /> <it-icon-list/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
menu menu
<it-icon-menu class="w-8 h-8" /> <it-icon-menu/>
</div> </div>
</div> </div>
<div class="mt-8 mb-8 flex flex-col gap-4 flex-wrap lg:flex-row"> <div class="mt-8 mb-8 flex flex-col gap-4 flex-wrap lg:flex-row">
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
ls-apply ls-apply
<it-icon-ls-apply class="w-8 h-8" /> <it-icon-ls-apply/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
ls-watch ls-watch
<it-icon-ls-watch class="w-8 h-8" /> <it-icon-ls-watch/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
ls-test ls-test
<it-icon-ls-test class="w-8 h-8" /> <it-icon-ls-test/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
ls-practice ls-practice
<it-icon-ls-practice class="w-8 h-8" /> <it-icon-ls-practice/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
ls-network ls-network
<it-icon-ls-network class="w-8 h-8" /> <it-icon-ls-network/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
ls-start ls-start
<it-icon-ls-start class="w-8 h-8" /> <it-icon-ls-start/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
ls-end ls-end
<it-icon-ls-end class="w-8 h-8" /> <it-icon-ls-end/>
</div> </div>
</div> </div>
<div class="mt-8 mb-8 flex flex-col gap-4 flex-wrap lg:flex-row"> <div class="mt-8 mb-8 flex flex-col gap-4 flex-wrap lg:flex-row">
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
smiley-happy smiley-happy
<it-icon-smiley-happy class="w-8 h-8" /> <it-icon-smiley-happy/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
smiley-thinking smiley-thinking
<it-icon-smiley-thinking class="w-8 h-8" /> <it-icon-smiley-thinking/>
</div> </div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
smiley-neutral smiley-neutral
<it-icon-smiley-neutral class="w-8 h-8" /> <it-icon-smiley-neutral/>
</div> </div>
</div> </div>
@ -214,6 +214,7 @@ function colorBgClass(color: string, value: number) {
<button class="btn-primary">Primary</button> <button class="btn-primary">Primary</button>
<button class="btn-secondary">Secondary</button> <button class="btn-secondary">Secondary</button>
<button class="btn-blue">Blue</button> <button class="btn-blue">Blue</button>
<button class="btn-text">Text</button>
<a class="btn-primary inline-block" href="/">Primary Link</a> <a class="btn-primary inline-block" href="/">Primary Link</a>
</div> </div>
@ -223,9 +224,7 @@ function colorBgClass(color: string, value: number) {
<button disabled class="btn-blue">Blue disabled</button> <button disabled class="btn-blue">Blue disabled</button>
</div> </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" <button type="button"
class="btn-primary inline-flex items-center p-3 rounded-full"> class="btn-primary inline-flex items-center p-3 rounded-full">
<it-icon-message class="h-5 w-5"></it-icon-message> <it-icon-message class="h-5 w-5"></it-icon-message>
@ -242,6 +241,13 @@ function colorBgClass(color: string, value: number) {
Button text Button text
<it-icon-message class="ml-3 -mr-1 h-5 w-5"></it-icon-message> <it-icon-message class="ml-3 -mr-1 h-5 w-5"></it-icon-message>
</button> </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> </div>
<h2 class="mt-8 mb-8">Dropdown (Work-in-progress)</h2> <h2 class="mt-8 mb-8">Dropdown (Work-in-progress)</h2>

View File

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

View File

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

View File

@ -4,8 +4,15 @@ set -o errexit
set -o pipefail set -o pipefail
set -o nounset set -o nounset
# TODO remove after stabilisation
python /app/manage.py reset_schema
python /app/manage.py collectstatic --noinput python /app/manage.py collectstatic --noinput
python /app/manage.py createcachetable python /app/manage.py createcachetable
python /app/manage.py migrate 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 /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.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -15,52 +15,21 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='UserCircleCompletion', name='CircleCompletion',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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()), ('circle_key', models.UUIDField()),
('json_data', models.JSONField(blank=True, default=dict)), ('completed', models.BooleanField(default=False)),
('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)),
('json_data', models.JSONField(blank=True, default=dict)), ('json_data', models.JSONField(blank=True, default=dict)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
], ],
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='usercirclecompletion', model_name='circlecompletion',
constraint=models.UniqueConstraint(fields=('user', 'circle_key'), name='unique_user_circle_completion'), constraint=models.UniqueConstraint(fields=('user', 'page_key'), name='unique_user_page_key'),
),
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'),
), ),
] ]

View File

@ -4,56 +4,25 @@ from django.db.models import UniqueConstraint
from vbv_lernwelt.core.models import User 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
user = models.ForeignKey(User, on_delete=models.CASCADE) 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() circle_key = models.UUIDField()
completed = models.BooleanField(default=True) completed = models.BooleanField(default=False)
json_data = models.JSONField(default=dict, blank=True) json_data = models.JSONField(default=dict, blank=True)
class Meta: class Meta:
constraints = [ constraints = [
UniqueConstraint( UniqueConstraint(
fields=['user', 'learning_content_key', ], fields=['user', 'page_key', ],
name='unique_user_learning_content_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 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: class Meta:
model = UserCircleCompletion model = CircleCompletion
fields = ['id', 'created_at', 'updated_at', 'user', 'circle_key', 'json_data'] 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) learning_content_key = str(learning_content.translation_key)
circle_key = str(learning_content.get_parent().translation_key) circle_key = str(learning_content.get_parent().translation_key)
response = self.client.post(f'/api/completion/complete_learning_content/', { mark_url = f'/api/completion/circle/mark/'
'learning_content_key': learning_content_key print(mark_url)
response = self.client.post(mark_url, {
'page_key': learning_content_key,
}) })
response_json = response.json() response_json = response.json()
print(json.dumps(response.json(), indent=2)) print(json.dumps(response.json(), indent=2))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response_json['circle_key'], circle_key) self.assertEqual(len(response_json), 1)
self.assertEqual( self.assertEqual(response_json[0]['page_key'], learning_content_key)
response_json['json_data']['completed_learning_contents'][learning_content_key]['learning_content_key'], self.assertEqual(response_json[0]['circle_key'], circle_key)
learning_content_key self.assertTrue(response_json[0]['completed'])
)
# test getting the circle data # 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() response_json = response.json()
print(json.dumps(response.json(), indent=2)) print(json.dumps(response.json(), indent=2))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response_json['circle_key'], circle_key) self.assertEqual(len(response_json), 1)
self.assertEqual( self.assertEqual(response_json[0]['page_key'], learning_content_key)
response_json['json_data']['completed_learning_contents'][learning_content_key]['learning_content_key'], self.assertEqual(response_json[0]['circle_key'], circle_key)
learning_content_key self.assertTrue(response_json[0]['completed'])
)

View File

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

View File

@ -1,54 +1,58 @@
from datetime import datetime
import structlog import structlog
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.response import Response from rest_framework.response import Response
from wagtail.models import Page
from vbv_lernwelt.completion.models import LearningContentCompletion, UserCircleCompletion from vbv_lernwelt.completion.models import CircleCompletion
from vbv_lernwelt.completion.serializers import UserCircleCompletionSerializer from vbv_lernwelt.completion.serializers import CircleCompletionSerializer
from vbv_lernwelt.learnpath.models import LearningContent from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.learnpath.utils import get_wagtail_type
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@api_view(['GET']) @api_view(['GET'])
def request_user_circle_completion(request, circle_key): def request_circle_completion(request, circle_key):
ucc = UserCircleCompletion.objects.filter( response_data = CircleCompletionSerializer(
user=request.user, CircleCompletion.objects.filter(user=request.user, circle_key=circle_key),
circle_key=circle_key, many=True,
) ).data
if ucc.count() > 0: return Response(status=200, data=response_data)
return Response(status=200, data=UserCircleCompletionSerializer(ucc.first()).data)
else:
return Response(status=200, data={})
@api_view(['POST']) @api_view(['POST'])
def complete_learning_content(request): def mark_circle_completion(request):
learning_content_key = request.data.get('learning_content_key') page_key = request.data.get('page_key')
learning_content = LearningContent.objects.get(translation_key=learning_content_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, user=request.user,
learning_content_key=learning_content_key, page_key=page_key,
circle_key=circle_key, circle_key=circle.translation_key,
) )
cc.page_type = page_type
cc.completed = completed
cc.save()
ucc, created = UserCircleCompletion.objects.get_or_create( response_data = CircleCompletionSerializer(
user=request.user, CircleCompletion.objects.filter(user=request.user, circle_key=circle.translation_key),
circle_key=circle_key, many=True,
) ).data
ucc.save()
logger.debug( logger.debug(
'learning content completed', 'page completed',
label='completion_api', label='completion_api',
circle_key=circle_key, circle_key=circle.translation_key,
learning_content_key=learning_content_key, circle_title=circle.title,
page_key=page_key,
page_type=page_type,
page_title=page.title,
user_id=request.user.id, 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 from django.db import migrations, models
import django.db.models.deletion 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)), ('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={ options={
'verbose_name': 'Learning Unit', 'verbose_name': 'Learning Content',
}, },
bases=('wagtailcore.page',), bases=('wagtailcore.page',),
), ),
@ -90,13 +90,22 @@ class Migration(migrations.Migration):
name='LearningUnit', name='LearningUnit',
fields=[ 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')), ('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={ options={
'verbose_name': 'Learning Unit', 'verbose_name': 'Learning Unit',
}, },
bases=('wagtailcore.page',), 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( migrations.CreateModel(
name='Topic', name='Topic',
fields=[ fields=[

View File

@ -85,7 +85,7 @@ class Circle(Page):
], use_json_field=True) ], use_json_field=True)
parent_page_types = ['learnpath.LearningPath'] parent_page_types = ['learnpath.LearningPath']
subpage_types = ['learnpath.LearningSequence', 'learnpath.LearningUnit'] subpage_types = ['learnpath.LearningSequence', 'learnpath.LearningUnit', 'learnpath.LearningContent']
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel('description'), FieldPanel('description'),
@ -100,7 +100,13 @@ class Circle(Page):
@classmethod @classmethod
def get_serializer_class(cls): 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): def full_clean(self, *args, **kwargs):
self.slug = find_available_slug(Circle, slugify(self.title, allow_unicode=True)) self.slug = find_available_slug(Circle, slugify(self.title, allow_unicode=True))
@ -144,14 +150,6 @@ class LearningUnit(Page):
parent_page_types = ['learnpath.Circle'] parent_page_types = ['learnpath.Circle']
subpage_types = [] subpage_types = []
questions = StreamField([
('question', blocks.CharBlock()),
], use_json_field=True)
content_panels = Page.content_panels + [
FieldPanel('questions'),
]
class Meta: class Meta:
verbose_name = "Learning Unit" verbose_name = "Learning Unit"
@ -160,7 +158,22 @@ class LearningUnit(Page):
@classmethod @classmethod
def get_serializer_class(cls): 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): class LearningContent(Page):
@ -200,7 +213,7 @@ class LearningContent(Page):
return display_title return display_title
class Meta: class Meta:
verbose_name = "Learning Unit" verbose_name = "Learning Content"
def full_clean(self, *args, **kwargs): def full_clean(self, *args, **kwargs):
self.slug = find_available_slug(LearningContent, slugify(self.title, allow_unicode=True)) 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 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): 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): class ItTypeField(wagtail_serializers.TypeField):
def to_representation(self, obj): def to_representation(self, obj):
name = type(obj)._meta.app_label + '.' + type(obj).__name__ name = get_wagtail_type(obj)
return name return name
class ItBaseSerializer(wagtail_serializers.BaseSerializer): class ItBaseSerializer(wagtail_serializers.BaseSerializer):
type = ItTypeField(read_only=True) 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 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, [])): class LearningPathSerializer(get_it_serializer_class(LearningPath, [])):
children = serializers.SerializerMethodField() 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.models import LearningPath, Topic, Circle, LearningSequence, LearningContent
from vbv_lernwelt.learnpath.tests.learning_path_factories import LearningPathFactory, TopicFactory, CircleFactory, \ from vbv_lernwelt.learnpath.tests.learning_path_factories import LearningPathFactory, TopicFactory, CircleFactory, \
LearningSequenceFactory, LearningContentFactory, VideoBlockFactory, PodcastBlockFactory, CompetenceBlockFactory, \ LearningSequenceFactory, LearningContentFactory, VideoBlockFactory, PodcastBlockFactory, CompetenceBlockFactory, \
ExerciseBlockFactory, DocumentBlockFactory, LearningUnitFactory ExerciseBlockFactory, DocumentBlockFactory, LearningUnitFactory, LearningUnitQuestionFactory
def create_default_learning_path(user=None): def create_default_learning_path(user=None):
@ -98,16 +98,34 @@ Fachspezialisten bei.
title='Einleitung Circle "Anlayse"', title='Einleitung Circle "Anlayse"',
parent=circe_analyse, parent=circe_analyse,
minutes=15, 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') 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( LearningContentFactory(
title='Ermittlung des Kundenbedarfs', title='Ermittlung des Kundenbedarfs',
parent=circe_analyse, parent=circe_analyse,
minutes=30, 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( LearningContentFactory(
title='Kundenbedürfnisse erkennen', title='Kundenbedürfnisse erkennen',
@ -123,7 +141,11 @@ Fachspezialisten bei.
) )
LearningSequenceFactory(title='Anwenden', parent=circe_analyse, icon='it-icon-ls-apply') 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( LearningContentFactory(
title='Versicherungsbedarf für Familien', title='Versicherungsbedarf für Familien',
parent=circe_analyse, parent=circe_analyse,
@ -137,7 +159,11 @@ Fachspezialisten bei.
contents=[('exercise', ExerciseBlockFactory())] 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( LearningContentFactory(
title='GmbH oder AG', title='GmbH oder AG',
parent=circe_analyse, parent=circe_analyse,
@ -151,7 +177,11 @@ Fachspezialisten bei.
contents=[('exercise', ExerciseBlockFactory())] 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( LearningContentFactory(
title='Motorfahrzeugversicherung', title='Motorfahrzeugversicherung',
parent=circe_analyse, parent=circe_analyse,
@ -177,8 +207,84 @@ Fachspezialisten bei.
contents=[('exercise', ExerciseBlockFactory())] 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') 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( LearningContentFactory(
title='Hausrat', title='Hausrat',
parent=circe_analyse, parent=circe_analyse,
@ -198,6 +304,43 @@ Fachspezialisten bei.
contents=[('competence', CompetenceBlockFactory())] 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) # 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_url = "https://www.vbv.ch/fileadmin/vbv/Videos/Statements_Externe/Janos_M/Testimonial_Janos_Mischler_PositiveEffekte.mp4"
# video_title = "Ausbildung ist pflicht" # video_title = "Ausbildung ist pflicht"

View File

@ -1,7 +1,7 @@
import factory
import wagtail_factories 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, \ from vbv_lernwelt.learnpath.models_learning_unit_content import VideoBlock, WebBasedTrainingBlock, PodcastBlock, \
CompetenceBlock, ExerciseBlock, DocumentBlock, KnowledgeBlock CompetenceBlock, ExerciseBlock, DocumentBlock, KnowledgeBlock
@ -42,8 +42,15 @@ class LearningUnitFactory(wagtail_factories.PageFactory):
model = LearningUnit model = LearningUnit
class LearningUnitQuestionFactory(wagtail_factories.PageFactory):
title = 'Frage zu Lerneinheit'
class Meta:
model = LearningUnitQuestion
class LearningContentFactory(wagtail_factories.PageFactory): class LearningContentFactory(wagtail_factories.PageFactory):
title = "Herzlich Willkommen" title = 'Lerninhalt'
class Meta: class Meta:
model = LearningContent 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 rest_framework.response import Response
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt 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.models import Circle, LearningPath
from vbv_lernwelt.learnpath.serializers import CircleSerializer, LearningPathSerializer from vbv_lernwelt.learnpath.serializers import LearningPathSerializer
@api_view(['GET']) @api_view(['GET'])
def circle_view(request, slug): def circle_view(request, slug):
circle = Circle.objects.get(slug=slug) circle = Circle.objects.get(slug=slug)
serializer = CircleSerializer(circle) serializer = Circle.get_serializer_class()(circle)
return Response(serializer.data) return Response(serializer.data)

View File

@ -4,7 +4,7 @@ example:
class icon_arrow_up extends HTMLElement { class icon_arrow_up extends HTMLElement {
connectedCallback() { 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" <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> </svg>
@ -20,7 +20,10 @@ customElements.define('it-icon-arrow-up', icon_arrow_up);
class {{ svg_icon.classname }} extends HTMLElement { class {{ svg_icon.classname }} extends HTMLElement {
connectedCallback() { 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 { @layer base {
.it-icon {
@apply w-8 h-8 inline-block
}
h1 { h1 {
@apply text-4xl md:text-5xl xl:text-7xl font-bold @apply text-4xl md:text-5xl xl:text-7xl font-bold
@ -67,5 +70,10 @@ svg {
disabled:opacity-50 disabled:cursor-not-allowed 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
}
} }