Merge branch 'develop' into feature/responsive-learnpfad

# Conflicts:
#	.dockerignore
This commit is contained in:
Lorenz Padberg 2022-08-10 10:44:57 +02:00
commit 1209a57e87
44 changed files with 875 additions and 270 deletions

View File

@ -13,4 +13,3 @@ venv
.git .git
.envrc .envrc

View File

@ -26,6 +26,8 @@
"@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",
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/typography": "^0.5.4",
"@testing-library/vue": "^6.6.0", "@testing-library/vue": "^6.6.0",
"@types/d3": "^7.4.0", "@types/d3": "^7.4.0",
"@types/jsdom": "^16.2.14", "@types/jsdom": "^16.2.14",

View File

@ -1,7 +1,11 @@
<template> <template>
<div id="app" class="flex flex-col min-h-screen"> <div id="app" class="flex flex-col min-h-screen">
<MainNavigationBar class="flex-none" /> <MainNavigationBar class="flex-none" />
<RouterView class="flex-auto" /> <RouterView class="flex-auto" v-slot="{ Component }">
<Transition mode="out-in" name="app">
<component :is="Component"></component>
</Transition>
</RouterView>
<Footer class="flex-none" /> <Footer class="flex-none" />
</div> </div>
</template> </template>
@ -11,6 +15,25 @@ import * as log from 'loglevel';
import MainNavigationBar from '@/components/MainNavigationBar.vue'; import MainNavigationBar from '@/components/MainNavigationBar.vue';
import Footer from '@/components/Footer.vue'; import Footer from '@/components/Footer.vue';
import {onMounted} from 'vue';
log.debug('App created'); log.debug('App created');
onMounted(() => {
log.debug('App mounted');
});
</script> </script>
<style lang="postcss" scoped>
.app-enter-active,
.app-leave-active {
transition: opacity 0.3s ease;
}
.app-enter-from,
.app-leave-to {
opacity: 0;
}
</style>

View File

@ -10,7 +10,7 @@ log.debug('Footer created');
class=" class="
px-8 px-8
py-4 py-4
bg-gray-100 bg-gray-200
border-t border-gray-500 border-t border-gray-500
"> ">
@2022 VBV @2022 VBV

View File

@ -1,27 +1,29 @@
<script setup> <script setup lang="ts">
import * as log from 'loglevel'; import * as log from 'loglevel';
import { reactive } from 'vue'; import { onMounted, reactive} from 'vue';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
import { useRoute } from 'vue-router'; import { useLearningPathStore } from '@/stores/learningPath';
import { useRoute, useRouter } from 'vue-router';
import { useAppStore } from '@/stores/app'; import { useAppStore } from '@/stores/app';
import IconLogout from "@/components/icons/IconLogout.vue";
import IconSettings from "@/components/icons/IconSettings.vue";
import ItDropdown from "@/components/ui/ItDropdown.vue";
import MobileMenu from "@/components/MobileMenu.vue"
log.debug('MainNavigationBar.vue created'); log.debug('MainNavigationBar created');
const route = useRoute() const route = useRoute()
const router = useRouter()
const userStore = useUserStore(); const userStore = useUserStore();
const appStore = useAppStore(); const appStore = useAppStore();
const learningPathStore = useLearningPathStore();
const state = reactive({showMenu: false}); const state = reactive({showMenu: false});
function toggleNav() { function toggleNav() {
console.log(state.showMenu);
state.showMenu = !state.showMenu; state.showMenu = !state.showMenu;
} }
function calcNavigationMobileOpenClasses() {
return state.showMenu ? ['fixed', 'w-full', 'h-screen'] : [];
}
function menuActive(checkPath) { function menuActive(checkPath) {
return route.path.startsWith(checkPath); return route.path.startsWith(checkPath);
} }
@ -30,30 +32,97 @@ function inLearningPath() {
return route.path.startsWith('/learningpath/') || route.path.startsWith('/circle/'); return route.path.startsWith('/learningpath/') || route.path.startsWith('/circle/');
} }
function getLearningPathStringProp (prop: 'title' | 'slug'): string {
return inLearningPath() && learningPathStore.learningPath ? learningPathStore.learningPath[prop] : '';
}
function learningPathName (): string {
return getLearningPathStringProp('title')
}
function learninPathSlug (): string {
return getLearningPathStringProp('slug')
}
function backButtonUrl() { function backButtonUrl() {
if (route.path.startsWith('/circle/')) { if (route.path.startsWith('/circle/')) {
return '/learningpath/versicherungsvermittlerin'; return '/learningpath/versicherungsvermittlerin';
} }
return '/'; return '/';
} }
function handleDropdownSelect(data) {
log.debug('Selected action:', data.action)
switch (data.action) {
case 'settings':
router.push('/profile')
break
case 'logout':
router.push('/logout')
break
default:
console.log('no action')
}
}
onMounted(() => {
log.debug('MainNavigationBar mounted');
})
const profileDropdownData = [
[
{
title: 'Kontoeinstellungen',
icon: IconSettings,
data: {
action: 'settings'
}
}
],
[
{
title: 'Abmelden',
icon: IconLogout,
data: {
action: 'logout'
}
},
]
]
</script> </script>
<template> <template>
<div v-if="appStore.showMainNavigationBar" class="navigation bg-blue-900" :class="calcNavigationMobileOpenClasses()"> <div>
<Teleport to="body">
<MobileMenu
:user="userStore"
:show="state.showMenu"
:learning-path-slug="learninPathSlug()"
:learning-path-name="learningPathName()"
@closemodal="state.showMenu = false"
/>
</Teleport>
<Transition name="nav">
<div v-if="appStore.showMainNavigationBar" class="navigation bg-blue-900">
<nav <nav
class=" class="
px-8 px-8
py-4 py-2
mx-auto mx-auto
lg:flex lg:justify-start lg:items-center lg:flex lg:justify-start lg:items-center lg:py-4
" "
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center">
<a
href="https://www.vbv.ch"
class="flex">
<it-icon-vbv class="h-8 w-16 mr-3 -mt-6 -ml-3"/>
</a>
<router-link <router-link
to="/" class="flex"> to="/"
<it-icon-vbv class="h-8 w-16 -mt-3 -ml-3"/> class="flex">
<div class=" <div class="
text-white text-white
text-2xl text-2xl
@ -62,16 +131,26 @@ function backButtonUrl() {
ml-1 ml-1
border-l border-white border-l border-white
" "
>myVBV >
myVBV
</div> </div>
</router-link> </router-link>
</div>
<div class="flex items-center lg:hidden">
<router-link
to="/messages"
class="nav-item flex flex-row items-center"
>
<it-icon-message class="w-8 h-8 mr-6"/>
</router-link>
<!-- Mobile menu button --> <!-- Mobile menu button -->
<div @click="toggleNav" class="flex lg:hidden"> <div @click="toggleNav" class="flex">
<button <button
type="button" type="button"
class=" class="
w-8 w-8
h-8
text-white text-white
hover:text-sky-500 hover:text-sky-500
focus:outline-none focus:text-sky-500 focus:outline-none focus:text-sky-500
@ -81,37 +160,20 @@ function backButtonUrl() {
</button> </button>
</div> </div>
</div> </div>
</div>
<!-- Mobile Menu open: "block", Menu closed: "hidden" --> <!-- Mobile Menu open: "block", Menu closed: "hidden" -->
<div <div
v-if="appStore.userLoaded && appStore.routingFinished && userStore.loggedIn "
:class="state.showMenu ? 'flex' : 'hidden'" :class="state.showMenu ? 'flex' : 'hidden'"
class=" class="
flex-auto flex-auto
flex-col
mt-8 mt-8
space-y-8
lg:flex lg:space-y-0 lg:flex-row lg:items-center lg:space-x-10 lg:mt-0 lg:flex lg:space-y-0 lg:flex-row lg:items-center lg:space-x-10 lg:mt-0
" "
> >
<router-link <router-link
v-if="userStore.loggedIn && !inLearningPath()" v-if="inLearningPath()"
to="/"
class="nav-item"
:class="{'nav-item--active': route.path === '/'}"
>
Cockpit
</router-link>
<router-link
v-if="userStore.loggedIn && !inLearningPath()"
to="/shop"
class="nav-item"
:class="{'nav-item--active': menuActive('/shop')}"
>
Shop
</router-link>
<router-link
v-if="userStore.loggedIn && inLearningPath()"
to="/learningpath/versicherungsvermittlerin" to="/learningpath/versicherungsvermittlerin"
class="nav-item" class="nav-item"
:class="{'nav-item--active': menuActive('/learningpath/')}" :class="{'nav-item--active': menuActive('/learningpath/')}"
@ -119,10 +181,24 @@ function backButtonUrl() {
Lernpfad Lernpfad
</router-link> </router-link>
<hr class="text-white lg:hidden">
<div class="hidden lg:list-item flex-auto"></div>
<router-link <router-link
v-if="userStore.loggedIn" v-if="inLearningPath()"
to="/competences/"
class="nav-item"
:class="{'nav-item--active': menuActive('/competences/')}"
>
Kompetenzprofil
</router-link>
<div class="hidden lg:block flex-auto"></div>
<router-link
to="/shop"
class="nav-item"
:class="{'nav-item--active': menuActive('/shop')}"
>
Shop
</router-link>
<router-link
to="/mediacenter" to="/mediacenter"
class="nav-item" class="nav-item"
:class="{'nav-item--active': menuActive('/mediacenter')}" :class="{'nav-item--active': menuActive('/mediacenter')}"
@ -130,17 +206,18 @@ function backButtonUrl() {
Mediathek Mediathek
</router-link> </router-link>
<router-link <router-link
v-if="userStore.loggedIn"
to="/messages" to="/messages"
class="nav-item flex flex-row items-center" class="nav-item flex flex-row items-center"
> >
<it-icon-message class="w-8 h-8 mr-2"/> <it-icon-message class="w-8 h-8 mr-6"/>
</router-link> </router-link>
<router-link <div class="nav-item flex items-center" v-if="userStore.loggedIn">
to="/profile" <ItDropdown
class="nav-item flex items-center" :button-classes="[]"
:list-items="profileDropdownData"
:align="'right'"
@select="handleDropdownSelect"
> >
<div v-if="userStore.loggedIn">
<div v-if="userStore.avatar_url"> <div v-if="userStore.avatar_url">
<img class="inline-block h-8 w-8 rounded-full" <img class="inline-block h-8 w-8 rounded-full"
:src="userStore.avatar_url" :src="userStore.avatar_url"
@ -149,12 +226,14 @@ function backButtonUrl() {
<div v-else> <div v-else>
{{ userStore.getFullName }} {{ userStore.getFullName }}
</div> </div>
</ItDropdown>
</div> </div>
<div v-else><a class="" href="/login">Login</a></div> <div v-else><a class="" href="/login">Login</a></div>
</router-link>
</div> </div>
</nav> </nav>
</div> </div>
</Transition>
</div>
</template> </template>
<style lang="postcss" scoped> <style lang="postcss" scoped>
@ -165,4 +244,16 @@ function backButtonUrl() {
.nav-item--active { .nav-item--active {
@apply underline underline-offset-[21px] decoration-sky-500 decoration-4 @apply underline underline-offset-[21px] decoration-sky-500 decoration-4
} }
.nav-enter-active,
.nav-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.nav-enter-from,
.nav-leave-to {
opacity: 0;
transform: translateY(-80px);
}
</style> </style>

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import ItFullScreenModal from '@/components/ui/ItFullScreenModal.vue'
import IconLogout from '@/components/icons/IconLogout.vue'
import IconSettings from '@/components/icons/IconSettings.vue'
import {useRouter} from "vue-router";
const router = useRouter()
const props = defineProps<{
show: boolean,
user: object,
learningPathName: string,
learningPathSlug: string
}>()
const emits = defineEmits(['closemodal'])
const clickLink = (to: string) => {
router.push(to)
emits('closemodal')
}
</script>
<template>
<ItFullScreenModal
:show="show"
@closemodal="$emit('closemodal')"
>
<div>
<div>
<div class="flex border-b border-gray-500 -mx-8 px-8 pb-4">
<div v-if="user.avatar_url">
<img class="inline-block h-16 w-16 rounded-full"
:src="user.avatar_url"
alt=""/>
</div>
<div class="ml-6">
<h3>{{user.first_name}} {{user.last_name}}</h3>
<button
@click="clickLink('/profile')"
class="mt-2 inline-block items-center">
<IconSettings class="inline-block" /><span class="ml-3">Kontoeinstellungen</span>
</button>
</div>
</div>
<div>
<div
class="mt-6 pb-6 border-b border-gray-500"
v-if="learningPathName">
<h4 class="text-gray-900 text-sm">Kurs: {{learningPathName}}</h4>
<ul class="mt-6">
<li><button @click="clickLink('/learningpath')">Lernpfad</button></li>
<li class="mt-6">Kompetenzprofil</li>
</ul>
</div>
<div class="mt-6 pb-6 border-b border-gray-500">
<ul>
<li>Shop</li>
<li class="mt-6">Mediathek</li>
</ul>
</div>
<div class="mt-6 items-center">
<IconLogout class="inline-block" /><span class="ml-1">Abmelden</span>
</div>
</div>
</div>
</div>
</ItFullScreenModal>
</template>

View File

@ -5,20 +5,6 @@ import * as _ from 'underscore'
import {useCircleStore} from '@/stores/circle'; import {useCircleStore} from '@/stores/circle';
import * as log from 'loglevel'; import * as log from 'loglevel';
const props = defineProps<{
width: {
default: 500,
type: number,
required: false
},
height: {
default: 500,
type: number,
required: false
},
}>()
const circleStore = useCircleStore(); const circleStore = useCircleStore();
function someFinished(learningSequence) { function someFinished(learningSequence) {
@ -70,12 +56,12 @@ const blue900 = '#00224D',
sky400 = '#72CAFF', sky400 = '#72CAFF',
sky500 = '#41B5FA' sky500 = '#41B5FA'
function render() { const width = 450
const width = 450, //props.width, const height = 450
height = 450, //props.height, const radius = Math.min(width, height) / 2.4
radius: number = Math.min(width, height) / 2.4,
arrowStrokeWidth = 2
function render() {
const arrowStrokeWidth = 2
const svg = d3.select('.circle-visualization') const svg = d3.select('.circle-visualization')
.attr('viewBox', `0 0 ${width} ${height}`) .attr('viewBox', `0 0 ${width} ${height}`)
@ -224,6 +210,8 @@ function render() {
<pre hidden>{{ pieData }}</pre> <pre hidden>{{ pieData }}</pre>
<pre hidden>{{render()}}</pre> <pre hidden>{{render()}}</pre>
<svg class="circle-visualization h-full"> <svg class="circle-visualization h-full">
<circle v-if="!circleStore.circle" :cx="width / 2" :cy="height / 2" :r="radius" :color="gray300"/>
<circle v-if="!circleStore.circle" :cx="width / 2" :cy="height / 2" :r="radius / 2.5" color="white"/>
</svg> </svg>
</div> </div>
</template> </template>

View File

@ -1,22 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import {Circle} from '@/services/circle'; import {Circle} from '@/services/circle';
import ItFullScreenModal from '@/components/ui/ItFullScreenModal.vue'
const props = defineProps<{ const props = defineProps<{
circle: Circle circle: Circle,
show: boolean
}>() }>()
const emits = defineEmits(['closemodal'])
</script> </script>
<template> <template>
<div class="circle-overview px-4 py-16 lg:px-16 lg:py-24 relative"> <ItFullScreenModal
<div :show="show"
class="w-8 h-8 absolute right-4 top-4 cursor-pointer" @closemodal="$emit('closemodal')"
@click="$emit('close')"
> >
<it-icon-close></it-icon-close>
</div>
<h1 class="">Überblick: Circle "{{ circle.title }}"</h1> <h1 class="">Überblick: Circle "{{ circle.title }}"</h1>
<p class="mt-8 text-xl">Hier zeigen wir dir, was du in diesem Circle lernen wirst.</p> <p class="mt-8 text-xl">Hier zeigen wir dir, was du in diesem Circle lernen wirst.</p>
@ -45,8 +44,7 @@ const props = defineProps<{
{{jobSituation.value}} {{jobSituation.value}}
</li> </li>
</ul> </ul>
</ItFullScreenModal>
</div>
</template> </template>
<style scoped> <style scoped>

View File

@ -82,5 +82,14 @@ const block = computed(() => {
</template> </template>
<style scoped> <style lang="scss" scoped>
$header-height: 77px;
$footer-height: 57px;
$content-height: $header-height + $footer-height;
.h-screen {
height: calc(100vh - $content-height);
}
</style> </style>

View File

@ -0,0 +1,6 @@
<template>
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.3292 15.7779C26.3292 15.7625 26.3474 15.7474 26.3536 15.7321C26.3619 15.71 26.3691 15.6877 26.3749 15.6649V15.6222C26.39 15.5448 26.39 15.465 26.3749 15.3876V15.3449C26.3691 15.3221 26.3619 15.2998 26.3536 15.2777C26.3536 15.2623 26.3383 15.2472 26.3292 15.2319C26.32 15.2132 26.3098 15.1949 26.2985 15.1771L26.262 15.1313L26.2315 15.0918L20.9236 9.44226C20.8147 9.31721 20.6597 9.24193 20.494 9.23364C20.3283 9.22556 20.1665 9.2851 20.0457 9.39888C19.9249 9.51244 19.8558 9.67046 19.8541 9.83634C19.8522 10.002 19.9181 10.1615 20.0363 10.2776L24.3779 14.8906H7.47836C7.1417 14.8906 6.86865 15.1634 6.86865 15.5003C6.86865 15.8371 7.14172 16.11 7.47836 16.11H24.3779L20.0363 20.7229C19.8213 20.9698 19.8398 21.3426 20.0782 21.5672C20.3169 21.7916 20.6898 21.7877 20.9236 21.5583L26.2315 15.918L26.2619 15.8784L26.2985 15.8327C26.3098 15.8149 26.32 15.7966 26.3291 15.7779L26.3292 15.7779Z" fill="#0A0A0A"/>
<path d="M6.65855 27H11.5884C12.5588 27 13.4894 26.6146 14.1755 25.9286C14.8616 25.2423 15.247 24.3119 15.247 23.3414V21.2926C15.247 20.9559 14.9741 20.6829 14.6372 20.6829C14.3006 20.6829 14.0275 20.956 14.0275 21.2926V23.3506C14.0275 23.9975 13.7706 24.6179 13.3132 25.0753C12.8558 25.5328 12.2354 25.7897 11.5885 25.7897H6.65861C6.01166 25.7897 5.39134 25.5328 4.93386 25.0753C4.47642 24.6179 4.21952 23.9975 4.21952 23.3506V7.65855C4.21952 7.01161 4.47642 6.39129 4.93386 5.93381C5.39131 5.47636 6.01166 5.21946 6.65861 5.21946H11.5885C12.2354 5.21946 12.8557 5.47637 13.3132 5.93381C13.7706 6.39125 14.0275 7.01161 14.0275 7.65855V9.7074C14.0275 10.0441 14.3006 10.3171 14.6372 10.3171C14.9741 10.3171 15.247 10.044 15.247 9.7074V7.65855C15.247 6.68817 14.8616 5.75774 14.1755 5.07143C13.4894 4.38535 12.5588 4 11.5884 4H6.65855C5.68817 4 4.75774 4.38535 4.07143 5.07143C3.38535 5.75768 3 6.68811 3 7.65855V23.3413C3 24.3117 3.38535 25.2422 4.07143 25.9285C4.75768 26.6145 5.68811 26.9999 6.65855 26.9999V27Z" fill="#0A0A0A"/>
</svg>
</template>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
import {reactive} from 'vue'
import {Menu, MenuButton, MenuItems, MenuItem} from '@headlessui/vue'
const props = defineProps<{
buttonClasses: [string],
listItems: [
[object]
],
align: 'left' | 'right'
}>()
const emit = defineEmits<{
(e: 'select', data: object): void
}>()
</script>
<template>
<Menu as="div" class="relative inline-block text-left">
<div>
<MenuButton
:class="buttonClasses"
>
<slot></slot>
</MenuButton>
</div>
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems
class="absolute mt-2 px-6 w-56 origin-top-right divide-y divide-gray-500 bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
:class="[align === 'left' ? 'left-0' : 'right-0']"
>
<div class="" v-for="section in listItems" :key="section">
<div class="px-1 py-1" v-for="item in section" :key="item">
<MenuItem>
<button
@click="$emit('select', item.data)"
class="text-black group flex w-full items-center px-0 py-2 text-sm"
>
<span class="inline-block pr-2">
<component
v-if="item.icon"
:is="item.icon"
></component>
</span>
{{item.title}}
</button>
</MenuItem>
</div>
</div>
</MenuItems>
</transition>
</Menu>
</template>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
// inspiration https://vuejs.org/examples/#modal
const props = defineProps<{
show: boolean
}>()
const emits = defineEmits(['closemodal'])
</script>
<template>
<Transition mode="in-out">
<div
v-if="show"
class="circle-overview px-4 py-16 lg:px-16 lg:py-24 fixed top-0 overflow-y-scroll bg-white h-full w-full">
<button
type="button"
class="w-8 h-8 absolute right-4 top-4 cursor-pointer"
@click="$emit('closemodal')"
>
<it-icon-close></it-icon-close>
</button>
<slot></slot>
</div>
</Transition>
</template>

View File

@ -1,14 +1,13 @@
import type {NavigationGuardWithThis, RouteLocationNormalized} from 'vue-router'; import type { NavigationGuardWithThis, RouteLocationNormalized } from 'vue-router'
import {useUserStore} from '@/stores/user'; import { useUserStore } from '@/stores/user'
export const updateLoggedIn: NavigationGuardWithThis<undefined> = (_to) => { export const updateLoggedIn: NavigationGuardWithThis<undefined> = (_to) => {
const loggedIn = getCookieValue('loginStatus') === 'true' const loggedIn = getCookieValue('loginStatus') === 'true'
const userStore = useUserStore() const userStore = useUserStore()
userStore.$patch({loggedIn}); userStore.$patch({ loggedIn })
if (loggedIn && !userStore.email) { if (loggedIn && !userStore.email) {
userStore.fetchUser(); userStore.fetchUser()
} }
} }

View File

@ -1,7 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import CockpitView from '@/views/CockpitView.vue'; import CockpitView from '@/views/CockpitView.vue'
import LoginView from '@/views/LoginView.vue'; import LoginView from '@/views/LoginView.vue'
import {redirectToLoginIfRequired, updateLoggedIn} from '@/router/guards'; import { redirectToLoginIfRequired, updateLoggedIn } from '@/router/guards'
import { useAppStore } from '@/stores/app'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -11,8 +12,8 @@ const router = createRouter({
component: LoginView, component: LoginView,
meta: { meta: {
// no login required -> so `public === true` // no login required -> so `public === true`
public: true public: true,
} },
}, },
{ {
path: '/', path: '/',
@ -38,28 +39,33 @@ const router = createRouter({
{ {
path: '/learningpath/:learningPathSlug', path: '/learningpath/:learningPathSlug',
component: () => import('../views/LearningPathView.vue'), component: () => import('../views/LearningPathView.vue'),
props: true props: true,
}, },
{ {
path: '/circle/:circleSlug', path: '/circle/:circleSlug',
component: () => import('../views/CircleView.vue'), component: () => import('../views/CircleView.vue'),
props: true props: true,
}, },
{ {
path: '/styleguide', path: '/styleguide',
component: () => import('../views/StyelGuideView.vue'), component: () => import('../views/StyelGuideView.vue'),
meta: { meta: {
public: true public: true,
} },
}, },
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
component: () => import('../views/404View.vue'), component: () => import('../views/404View.vue'),
}, },
] ],
}) })
router.beforeEach(updateLoggedIn) router.beforeEach(updateLoggedIn)
router.beforeEach(redirectToLoginIfRequired) router.beforeEach(redirectToLoginIfRequired)
router.afterEach((to, from) => {
const appStore = useAppStore();
appStore.routingFinished = true;
});
export default router export default router

View File

@ -1,13 +1,17 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
export type AppState = { export type AppState = {
showMainNavigationBar: boolean; userLoaded: boolean
routingFinished: boolean
showMainNavigationBar: boolean
} }
export const useAppStore = defineStore({ export const useAppStore = defineStore({
id: 'app', id: 'app',
state: () => ({ state: () => ({
showMainNavigationBar: true, showMainNavigationBar: true,
userLoaded: false,
routingFinished: false,
} as AppState), } as AppState),
getters: { getters: {
}, },

View File

@ -25,7 +25,7 @@ export const useLearningPathStore = defineStore({
return this.learningPath; return this.learningPath;
} }
try { try {
const learningPathData = await itGet(`/learnpath/api/learningpath/${slug}/`); const learningPathData = await itGet(`/learnpath/api/page/${slug}/`);
const completionData = await itGet(`/api/completion/learning_path/${learningPathData.translation_key}/`); const completionData = await itGet(`/api/completion/learning_path/${learningPathData.translation_key}/`);
this.learningPath = learningPathData; this.learningPath = learningPathData;
@ -54,8 +54,6 @@ export const useLearningPathStore = defineStore({
}) })
this.learningPath.topics.push(topic); this.learningPath.topics.push(topic);
console.log('#######################');
console.log(this.learningPath);
} }
return this.learningPath; return this.learningPath;
} catch (error) { } catch (error) {

View File

@ -2,6 +2,7 @@ import * as log from 'loglevel';
import {defineStore} from 'pinia' import {defineStore} from 'pinia'
import {itGet, itPost} from '@/fetchHelpers'; import {itGet, itPost} from '@/fetchHelpers';
import {useAppStore} from '@/stores/app';
// typed state https://stackoverflow.com/questions/71012513/when-using-pinia-and-typescript-how-do-you-use-an-action-to-set-the-state // typed state https://stackoverflow.com/questions/71012513/when-using-pinia-and-typescript-how-do-you-use-an-action-to-set-the-state
export type UserState = { export type UserState = {
@ -46,11 +47,14 @@ export const useUserStore = defineStore({
} }
}, },
fetchUser() { fetchUser() {
const appStore = useAppStore();
itGet('/api/core/me/').then((data) => { itGet('/api/core/me/').then((data) => {
this.$state = data; this.$state = data;
this.loggedIn = true; this.loggedIn = true;
appStore.userLoaded = true;
}).catch(() => { }).catch(() => {
this.loggedIn = false; this.loggedIn = false;
appStore.userLoaded = true;
}) })
} }
} }

View File

@ -2,12 +2,12 @@
import * as log from 'loglevel'; import * as log from 'loglevel';
import LearningSequence from '@/components/circle/LearningSequence.vue'; import LearningSequence from '@/components/circle/LearningSequence.vue';
import CircleOverview from '@/components/circle/CircleOverview.vue'; import CircleOverview from '@/components/circle/CircleOverview.vue';
import CircleDiagram from '@/components/circle/CircleDiagram.vue';
import LearningContent from '@/components/circle/LearningContent.vue'; import LearningContent from '@/components/circle/LearningContent.vue';
import {onMounted} from 'vue' import {onMounted} from 'vue'
import {useCircleStore} from '@/stores/circle'; import {useCircleStore} from '@/stores/circle';
import SelfEvaluation from '@/components/circle/SelfEvaluation.vue'; import SelfEvaluation from '@/components/circle/SelfEvaluation.vue';
import CircleDiagram from '@/components/circle/CircleDiagram.vue';
log.debug('CircleView.vue created'); log.debug('CircleView.vue created');
@ -16,34 +16,39 @@ const props = defineProps<{
}>() }>()
const circleStore = useCircleStore(); const circleStore = useCircleStore();
circleStore.loadCircle(props.circleSlug);
onMounted(async () => { onMounted(async () => {
log.info('CircleView.vue mounted'); log.info('CircleView.vue mounted');
await circleStore.loadCircle(props.circleSlug);
}); });
</script> </script>
<template> <template>
<Transition> <div>
<div v-if="circleStore.page === 'OVERVIEW'"> <Teleport to="body">
<CircleOverview :circle="circleStore.circle" @close="circleStore.page = 'INDEX'"/> <CircleOverview
</div> :circle="circleStore.circle"
<div v-else-if="circleStore.page === 'LEARNING_CONTENT'"> :show="circleStore.page === 'OVERVIEW'"
@closemodal="circleStore.page = 'INDEX'"
/>
</Teleport>
<Transition mode="out-in">
<div v-if="circleStore.page === 'LEARNING_CONTENT'">
<LearningContent :key="circleStore.currentLearningContent.translation_key"/> <LearningContent :key="circleStore.currentLearningContent.translation_key"/>
</div> </div>
<div v-else-if="circleStore.page === 'SELF_EVALUATION'"> <div v-else-if="circleStore.page === 'SELF_EVALUATION'">
<SelfEvaluation :key="circleStore.currentSelfEvaluation.translation_key"/> <SelfEvaluation :key="circleStore.currentSelfEvaluation.translation_key"/>
</div> </div>
<div v-else-if="circleStore.circle"> <div v-else>
<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-4 lg:px-8 lg:py-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">
{{ circleStore.circle.title }} {{ circleStore.circle?.title }}
</h1> </h1>
<div v-if="circleStore.circle" class="w-full mt-8"> <div class="w-full mt-8">
<CircleDiagram></CircleDiagram> <CircleDiagram></CircleDiagram>
</div> </div>
@ -65,7 +70,7 @@ onMounted(async () => {
<div class="block border border-gray-500 mt-8 p-6"> <div class="block border border-gray-500 mt-8 p-6">
<h3 class="text-blue-dark">Das lernst du in diesem Circle.</h3> <h3 class="text-blue-dark">Das lernst du in diesem Circle.</h3>
<div class="prose mt-4"> <div class="prose mt-4">
{{ circleStore.circle.description }} {{ circleStore.circle?.description }}
</div> </div>
<button class="btn-primary mt-4" @click="circleStore.page = 'OVERVIEW'">Erfahre mehr dazu</button> <button class="btn-primary mt-4" @click="circleStore.page = 'OVERVIEW'">Erfahre mehr dazu</button>
@ -81,9 +86,9 @@ onMounted(async () => {
</div> </div>
</div> </div>
<div class="flex-auto bg-gray-100 px-4 py-8 lg:px-24"> <div class="flex-auto bg-gray-200 px-4 py-8 lg:px-24">
<div <div
v-for="learningSequence in circleStore.circle.learningSequences" v-for="learningSequence in circleStore.circle?.learningSequences || []"
:key="learningSequence.translation_key" :key="learningSequence.translation_key"
> >
<LearningSequence <LearningSequence
@ -97,6 +102,7 @@ onMounted(async () => {
</div> </div>
</div> </div>
</Transition> </Transition>
</div>
</template> </template>
<style scoped> <style scoped>
@ -111,7 +117,4 @@ onMounted(async () => {
opacity: 0; opacity: 0;
} }
.v-enter-active {
transition-delay: 0.3s;
}
</style> </style>

View File

@ -9,7 +9,7 @@ const userStore = useUserStore();
</script> </script>
<template> <template>
<main class="px-8 py-8 lg:px-12 lg:py-12 bg-gray-100"> <main class="px-8 py-8 lg:px-12 lg:py-12 bg-gray-200">
<h1>Willkommen, {{userStore.first_name}}</h1> <h1>Willkommen, {{userStore.first_name}}</h1>
<h2 class="mt-12">Deine Kurse</h2> <h2 class="mt-12">Deine Kurse</h2>

View File

@ -26,7 +26,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="bg-gray-100" v-if="learningPathStore.learningPath"> <div class="bg-gray-200" v-if="learningPathStore.learningPath">
<div class="learningpath flex flex-col"> <div class="learningpath flex flex-col">
<div class="flex flex-col h-max"> <div class="flex flex-col h-max">

View File

@ -3,6 +3,9 @@
import {reactive} from 'vue' import {reactive} from 'vue'
import {Listbox, ListboxButton, ListboxOption, ListboxOptions} from '@headlessui/vue' import {Listbox, ListboxButton, ListboxOption, ListboxOptions} from '@headlessui/vue'
import ItCheckbox from '@/components/ui/ItCheckbox.vue'; import ItCheckbox from '@/components/ui/ItCheckbox.vue';
import ItDropdown from "@/components/ui/ItDropdown.vue";
import IconLogout from "@/components/icons/IconLogout.vue"
import IconSettings from "@/components/icons/IconSettings.vue"
const state = reactive({ const state = reactive({
@ -22,7 +25,35 @@ const state = reactive({
dropdownSelected: {id: 8}, dropdownSelected: {id: 8},
}) })
const dropdownData = [
[
{
title: 'Option 1',
icon: IconLogout,
data: {}
},
{
title: 'Option 2',
icon: null,
data: {
test: 12
}
}
],
[
{
title: 'Option 3',
icon: IconSettings,
data: {
amount: 34
}
},
]
]
// TODO: die CSS-Klasse für die Farben wird hier in der StyleGuideView.vue generiert.
// deshalb muss man diese CSS-Klassen in tailwind.config.js "safelist"en, wenn diese sonst
// noch nirgendwo verwendet werden.
const colors = ['blue', 'sky', 'orange', 'green', 'red', 'gray',]; const colors = ['blue', 'sky', 'orange', 'green', 'red', 'gray',];
const colorValues = [100, 200, 300, 400, 500, 600, 700, 800, 900,]; const colorValues = [100, 200, 300, 400, 500, 600, 700, 800, 900,];
@ -30,6 +61,10 @@ function colorBgClass(color: string, value: number) {
return `bg-${color}-${value}`; return `bg-${color}-${value}`;
} }
function log(data: any) {
console.log(data);
}
</script> </script>
<template> <template>
@ -272,11 +307,11 @@ function colorBgClass(color: string, value: number) {
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" <transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100"
leave-to-class="opacity-0"> leave-to-class="opacity-0">
<ListboxOptions <ListboxOptions
class="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 py-1 text-base ring-1 ring-gray-900 ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"> class="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<ListboxOption as="template" v-for="person in state.dropdownValues" :key="person.id" :value="person" <ListboxOption as="template" v-for="person in state.dropdownValues" :key="person.id" :value="person"
v-slot="{ active, selected }"> v-slot="{ active, selected }">
<li <li
:class="[active ? 'text-white bg-blue-900' : 'text-gray-900', 'cursor-default select-none relative py-2 pl-3 pr-9']"> :class="[active ? 'text-white bg-blue-900' : 'text-black', 'cursor-default select-none relative py-2 pl-3 pr-9']">
<span :class="[state.dropdownSelected ? 'font-semibold' : 'font-normal', 'block truncate']"> <span :class="[state.dropdownSelected ? 'font-semibold' : 'font-normal', 'block truncate']">
{{ person.name }} {{ person.name }}
</span> </span>
@ -298,6 +333,17 @@ function colorBgClass(color: string, value: number) {
<ItCheckbox disabled class="mt-4">Disabled</ItCheckbox> <ItCheckbox disabled class="mt-4">Disabled</ItCheckbox>
<h2 class="mt-8 mb-8">Dropdown</h2>
<div class="h-60">
<ItDropdown
:button-classes="['btn-primary']"
:list-items="dropdownData"
:align="'left'"
@select="log"
>Click Me</ItDropdown>
</div>
</main> </main>
</template> </template>

View File

@ -4,7 +4,8 @@ module.exports = {
content: [ content: [
'./index.html', './index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}', './src/**/*.{vue,js,ts,jsx,tsx}',
// '../server/vbv_lernwelt/**/*.{html,js}', // TODO: wenn man den server-pfad auch angibt wird Tailwind langsamer?! (Startzeit erhöht sich stark...)
// '../server/vbv_lernwelt/**/*.{html,js,py}',
], ],
theme: { theme: {
fontFamily: { fontFamily: {
@ -21,8 +22,9 @@ module.exports = {
transparent: 'transparent', transparent: 'transparent',
current: 'currentColor', current: 'currentColor',
'white': '#ffffff', 'white': '#ffffff',
'black': '#0A0A0A',
'blue': { 'blue': {
700: '#1A5197', 700: '#2957A6',
900: '#00224D', 900: '#00224D',
}, },
'sky': { 'sky': {
@ -31,21 +33,21 @@ module.exports = {
}, },
'orange': { 'orange': {
500: '#FE955A', 500: '#FE955A',
600: '#F37F3E', 600: '#E68B4E',
}, },
'green': { 'green': {
500: '#3EDF9C', 500: '#54CE8B',
600: '#17D29A', 600: '#5BB782',
}, },
'red': { 'red': {
500: '#DE3618', 500: '#EF7D68',
}, },
'gray': { 'gray': {
100: '#EDF2F6', 200: '#EDF2F6',
300: '#E0E5EC', 300: '#E0E5EC',
500: '#B1C1CA', 500: '#B1C1CA',
700: '#6F787E', 700: '#6F787E',
900: '#0A0A0A', 900: '#585F63',
}, },
} }
}, },

View File

@ -3,7 +3,7 @@
@tailwind utilities; @tailwind utilities;
html { html {
@apply text-gray-900 @apply text-black
} }
svg { svg {
@ -24,24 +24,37 @@ svg {
} }
h2 { h2 {
@apply text-3xl xl:text-4xl font-bold @apply text-2xl md:text-3xl xl:text-4xl font-bold
} }
.heading-2 { .heading-2 {
@apply text-3xl xl:text-4xl font-bold @apply text-2xl md:text-3xl xl:text-4xl font-bold
} }
h3 { h3 {
@apply text-2xl font-bold @apply text-xl xl:text-2xl font-bold
} }
.heading-3 { .heading-3 {
@apply text-3xl xl:text-4xl font-bold @apply text-xl xl:text-2xl font-bold
} }
.link { .link {
@apply underline underline-offset-2 @apply underline underline-offset-2
} }
.link-large {
@apply text-lg underline xl:text-xl
}
.text-large {
@apply text-lg xl:text-xl
}
.text-bold {
@apply text-base font-bold
}
} }
@layer components { @layer components {
@ -59,7 +72,7 @@ svg {
.btn-secondary { .btn-secondary {
@apply font-bold py-2 px-4 align-middle inline-block @apply font-bold py-2 px-4 align-middle inline-block
bg-white text-blue-900 border-2 border-blue-900 bg-white text-blue-900 border-2 border-blue-900
hover:bg-gray-100 hover:bg-gray-200
disabled:opacity-50 disabled:cursor-not-allowed disabled:opacity-50 disabled:cursor-not-allowed
} }

Binary file not shown.

33
scripts/count_queries.py Normal file
View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
import json
import os
import sys
import django
sys.path.append("../server")
os.environ.setdefault("IT_APP_ENVIRONMENT", "development")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.base")
django.setup()
from wagtail.models import Page
def main():
from django.conf import settings
settings.DEBUG = True
from django.db import connection
from django.db import reset_queries
reset_queries()
page = Page.objects.get(slug='versicherungsvermittlerin', locale__language_code='de-CH')
serializer = page.specific.get_serializer_class()(page.specific)
print(serializer.data)
print(len(json.dumps(serializer.data)))
print(len(connection.queries))
if __name__ == '__main__':
main()

View File

@ -486,32 +486,37 @@ ALLOWED_HOSTS = env.list(
# CACHES # CACHES
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": env( "BACKEND": env("IT_DJANGO_CACHE_BACKEND", default="django.core.cache.backends.db.DatabaseCache"),
"IT_DJANGO_CACHE_BACKEND",
default="django.core.cache.backends.db.DatabaseCache",
),
"LOCATION": env("IT_DJANGO_CACHE_LOCATION", default="django_cache_table"), "LOCATION": env("IT_DJANGO_CACHE_LOCATION", default="django_cache_table"),
} },
} }
if "django_redis.cache.RedisCache" in env("IT_DJANGO_CACHE_BACKEND", default=""): if "django_redis.cache.RedisCache" in env("IT_DJANGO_CACHE_BACKEND", default=""):
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": env( "BACKEND": "django_redis.cache.RedisCache",
"IT_DJANGO_CACHE_BACKEND", "LOCATION": env("IT_DJANGO_CACHE_LOCATION"),
default="django.core.cache.backends.db.DatabaseCache",
),
"LOCATION": env("IT_DJANGO_CACHE_LOCATION", default="django_cache_table"),
"OPTIONS": { "OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient", "CLIENT_CLASS": "django_redis.client.DefaultClient",
# Mimicing memcache behavior.
# https://github.com/jazzband/django-redis#memcached-exceptions-behavior
"IGNORE_EXCEPTIONS": True, "IGNORE_EXCEPTIONS": True,
}, },
},
} }
CACHES["learning_path_cache"] = {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"LOCATION": "django_cache_learning_path",
} }
# OAuth/OpenId Connect # OAuth/OpenId Connect
IT_OAUTH_TENANT_ID = env.str("IT_OAUTH_TENANT_ID", default=None)
if IT_OAUTH_TENANT_ID:
IT_OAUTH_AUTHORIZE_PARAMS = {
'tenant_id': IT_OAUTH_TENANT_ID
}
else:
IT_OAUTH_AUTHORIZE_PARAMS = {}
OAUTH = { OAUTH = {
"client_name": env("IT_OAUTH_CLIENT_NAME", default="lernetz"), "client_name": env("IT_OAUTH_CLIENT_NAME", default="lernetz"),
@ -519,10 +524,11 @@ OAUTH = {
"client_secret": env("IT_OAUTH_CLIENT_SECRET", default=""), "client_secret": env("IT_OAUTH_CLIENT_SECRET", default=""),
"access_token_url": env("IT_OAUTH_ACCESS_TOKEN_URL", default="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/token"), "access_token_url": env("IT_OAUTH_ACCESS_TOKEN_URL", default="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/token"),
"authorize_url": env("IT_OAUTH_AUTHORIZE_URL", default="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/auth"), "authorize_url": env("IT_OAUTH_AUTHORIZE_URL", default="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/auth"),
"authorize_params": IT_OAUTH_AUTHORIZE_PARAMS,
"api_base_url": env("IT_OAUTH_API_BASE_URL", default="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/"), "api_base_url": env("IT_OAUTH_API_BASE_URL", default="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/"),
"local_redirect_uri": env("IT_OAUTH_LOCAL_DIRECT_URI", default="http://localhost:3000/sso/callback/"), "local_redirect_uri": env("IT_OAUTH_LOCAL_DIRECT_URI", default="http://localhost:8000/sso/callback/"),
"client_kwargs": { "client_kwargs": {
'scope': '', 'scope': env("IT_OAUTH_SCOPE", default=''),
'token_endpoint_auth_method': 'client_secret_post', 'token_endpoint_auth_method': 'client_secret_post',
'token_placement': 'header', 'token_placement': 'header',
} }

View File

@ -9,12 +9,12 @@ def create_default_users(user_model=User, group_model=Group, default_password=No
default_password = 'test' default_password = 'test'
admin_group, created = group_model.objects.get_or_create(name='admin_group') admin_group, created = group_model.objects.get_or_create(name='admin_group')
content_creator_grop, created = group_model.objects.get_or_create(name='content_creator_grop') _content_creator_grop, _created = group_model.objects.get_or_create(name='content_creator_grop')
student_group, created = group_model.objects.get_or_create(name='student_group') student_group, created = group_model.objects.get_or_create(name='student_group')
def _create_student_user(email, first_name, last_name, avatar_url=''): def _create_student_user(email, first_name, last_name, avatar_url='', password=default_password):
student_user, created = _get_or_create_user( student_user, created = _get_or_create_user(
user_model=user_model, username=email, password=default_password, user_model=user_model, username=email, password=password,
) )
student_user.first_name = first_name student_user.first_name = first_name
student_user.last_name = last_name student_user.last_name = last_name
@ -73,7 +73,24 @@ def create_default_users(user_model=User, group_model=Group, default_password=No
email='christoph.bosshard@vbv-afa.ch', email='christoph.bosshard@vbv-afa.ch',
first_name='Christoph', first_name='Christoph',
last_name='Bosshard', last_name='Bosshard',
avatar_url='/static/avatars/avatar_christoph.jpg' avatar_url='/static/avatars/avatar_christoph.jpg',
password='myvbv1234'
)
_create_student_user(
email='alexandra.vangelista@lernetz.ch',
first_name='Alexandra',
last_name='Vangelista',
avatar_url='/static/avatars/avatar_alexandra.png',
password='myvbv1234'
)
_create_student_user(
email='chantal.rosenberg@vbv-afa.ch',
first_name='Chantal',
last_name='Rosenberg',
avatar_url='/static/avatars/avatar_chantal.png',
password='myvbv1234'
) )

View File

@ -2,6 +2,8 @@ from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.db.models import JSONField from django.db.models import JSONField
from vbv_lernwelt.core.managers import UserManager
class User(AbstractUser): class User(AbstractUser):
""" """
@ -13,6 +15,8 @@ class User(AbstractUser):
avatar_url = models.CharField(max_length=254, blank=True, default='') avatar_url = models.CharField(max_length=254, blank=True, default='')
email = models.EmailField('email address', unique=True) email = models.EmailField('email address', unique=True)
objects = UserManager()
class SecurityRequestResponseLog(models.Model): class SecurityRequestResponseLog(models.Model):
label = models.CharField(max_length=255, blank=True, default="") label = models.CharField(max_length=255, blank=True, default="")

View File

@ -29,6 +29,12 @@ class LearningPath(Page):
def __str__(self): def __str__(self):
return f"{self.title}" return f"{self.title}"
@classmethod
def get_serializer_class(cls):
return get_it_serializer_class(
cls, ['id', 'title', 'slug', 'type', 'translation_key', 'children']
)
class Topic(Page): class Topic(Page):
# title = models.TextField(default='') # title = models.TextField(default='')
@ -55,8 +61,9 @@ class Topic(Page):
@classmethod @classmethod
def get_serializer_class(cls): def get_serializer_class(cls):
return get_it_serializer_class(cls, return get_it_serializer_class(
field_names=['id', 'title', 'slug', 'type', 'translation_key', 'is_visible', ]) cls, field_names=['id', 'title', 'slug', 'type', 'translation_key', 'is_visible', ]
)
class Meta: class Meta:
verbose_name = "Topic" verbose_name = "Topic"
@ -143,6 +150,13 @@ class LearningSequence(Page):
def get_admin_display_title(self): def get_admin_display_title(self):
return f'{self.icon} {self.draft_title}' return f'{self.icon} {self.draft_title}'
def get_admin_display_title_html(self):
return f'''
<span style="display: inline-flex; align-items: center; font-size: 1.25rem; font-weight: 700;">
<{self.icon} style="height: 32px; width: 32px;"></{self.icon}>
<span style="margin-left: 8px;">{self.draft_title}</span>
</span>'''
def full_clean(self, *args, **kwargs): def full_clean(self, *args, **kwargs):
super(LearningSequence, self).full_clean(*args, **kwargs) super(LearningSequence, self).full_clean(*args, **kwargs)
@ -161,6 +175,9 @@ class LearningUnit(Page):
def get_serializer_class(cls): def get_serializer_class(cls):
return get_it_serializer_class(cls, field_names=['id', 'title', 'slug', 'type', 'translation_key', 'children']) return get_it_serializer_class(cls, field_names=['id', 'title', 'slug', 'type', 'translation_key', 'children'])
def get_admin_display_title_html(self):
return f'<span style="font-weight: 700; font-size: 20px;">{self.draft_title}</span>'
class LearningUnitQuestion(Page): class LearningUnitQuestion(Page):
parent_page_types = ['learnpath.LearningUnit'] parent_page_types = ['learnpath.LearningUnit']
@ -213,6 +230,13 @@ class LearningContent(Page):
return display_title return display_title
def get_admin_display_title_html(self):
return f'''
<span style="display: inline-flex; align-items: center;">
<it-icon-checkbox-unchecked style="height: 24px; width: 24px;"></it-icon-checkbox-unchecked>
<span style="margin-left: 8px;">{self.get_admin_display_title()}</span>
</span>'''
class Meta: class Meta:
verbose_name = "Learning Content" verbose_name = "Learning Content"

View File

@ -20,5 +20,20 @@ class ItBaseSerializer(wagtail_serializers.BaseSerializer):
meta_fields = [] meta_fields = []
def __init__(self, *args, **kwargs):
self.descendants = kwargs.pop('descendants', None)
super().__init__(*args, **kwargs)
def get_children(self, obj): def get_children(self, obj):
return [c.specific.get_serializer_class()(c.specific).data for c in obj.get_children()] if not self.descendants:
self.descendants = [p for p in obj.get_descendants().specific()]
children = _get_children(self.descendants, obj)
return [c.specific.get_serializer_class()(c.specific, descendants=self.descendants).data for c in children]
def _get_descendants(pages, obj):
return [c for c in pages if c.path.startswith(obj.path) and c.depth >= obj.depth]
def _get_children(pages, obj):
return [c for c in pages if c.path.startswith(obj.path) and obj.depth + 1 == c.depth]

View File

@ -1,20 +0,0 @@
from rest_framework import serializers
from vbv_lernwelt.learnpath.models import Circle, LearningPath
from vbv_lernwelt.learnpath.serializer_helpers import get_it_serializer_class
class LearningPathSerializer(get_it_serializer_class(LearningPath, [])):
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']

View File

@ -0,0 +1,16 @@
import structlog
from django.core.cache import caches
from django.db.models.signals import post_delete, post_save
from wagtail.models import Page
logger = structlog.get_logger(__name__)
def invalidate_learning_path_cache(sender, **kwargs):
logger.debug('invalidate learning_path_cache', label='learning_path_cache')
caches['learning_path_cache'].clear()
for subclass in Page.__subclasses__():
post_save.connect(invalidate_learning_path_cache, subclass)
post_delete.connect(invalidate_learning_path_cache, subclass)

View File

@ -1,10 +1,8 @@
from django.urls import path, re_path from django.urls import path, re_path
from .views import circle_view, generate_web_component_icons from .views import generate_web_component_icons, page_api_view
from .views import learningpath_view
urlpatterns = [ urlpatterns = [
path(r"api/circle/<slug:slug>/", circle_view, name="circle_view"), path(r"api/page/<slug:slug>/", page_api_view, name="page_api_view"),
path(r"api/learningpath/<slug:slug>/", learningpath_view, name="learningpath_view"),
re_path(r"icons/$", generate_web_component_icons, name="generate_web_component_icons"), re_path(r"icons/$", generate_web_component_icons, name="generate_web_component_icons"),
] ]

View File

@ -4,18 +4,19 @@ from pathlib import Path
from django.conf import settings from django.conf import settings
from django.shortcuts import render from django.shortcuts import render
from django.views.decorators.cache import cache_page
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.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, LearningPath
from vbv_lernwelt.learnpath.serializers import LearningPathSerializer
@api_view(['GET']) @api_view(['GET'])
def circle_view(request, slug): @cache_page(60 * 60 * 8, cache="learning_path_cache")
circle = Circle.objects.get(slug=slug) def page_api_view(request, slug):
serializer = Circle.get_serializer_class()(circle) page = Page.objects.get(slug=slug, locale__language_code='de-CH')
serializer = page.specific.get_serializer_class()(page.specific)
return Response(serializer.data) return Response(serializer.data)
@ -38,9 +39,3 @@ def generate_web_component_icons(request):
context={'svg_files': svg_files}, context={'svg_files': svg_files},
content_type="application/javascript" content_type="application/javascript"
) )
@api_view(['GET'])
def learningpath_view(request, slug):
learning_path = LearningPath.objects.get(slug=slug, locale__language_code='de-CH')
serializer = LearningPathSerializer(learning_path)
return Response(serializer.data)

View File

@ -23,7 +23,7 @@ oauth.register(
access_token_url=settings.OAUTH["access_token_url"], access_token_url=settings.OAUTH["access_token_url"],
access_token_params=None, access_token_params=None,
authorize_url=settings.OAUTH["authorize_url"], authorize_url=settings.OAUTH["authorize_url"],
authorize_params=None, authorize_params=settings.OAUTH["authorize_params"],
api_base_url=settings.OAUTH["api_base_url"], api_base_url=settings.OAUTH["api_base_url"],
client_kwargs=settings.OAUTH["client_kwargs"] client_kwargs=settings.OAUTH["client_kwargs"]
) )

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><circle cx="15" cy="15" r="14" fill="#3ede9b"/><g><path d="M11.69,11.04c0,.66-.54,1.2-1.2,1.2s-1.2-.54-1.2-1.2,.54-1.2,1.2-1.2,1.2,.54,1.2,1.2Z" fill="#0a0a0a"/><path d="M20.71,11.04c0,.66-.54,1.2-1.2,1.2s-1.2-.54-1.2-1.2,.54-1.2,1.2-1.2,1.2,.54,1.2,1.2Z" fill="#0a0a0a"/></g><path d="M9.39,17.13c1.57,1.16,3.51,1.86,5.61,1.86s4.04-.69,5.61-1.86" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.25"/></svg> <?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 30 30"><defs><clipPath id="b"><circle cx="15" cy="15" r="14" fill="none"/></clipPath></defs><circle cx="15" cy="15" r="14" fill="#5bb782"/><g clip-path="url(#b)"><ellipse cx="15" cy="13.85" rx="12.85" ry="12.22" fill="#64d996"/></g><g><path d="M11.69,11.04c0,.66-.54,1.2-1.2,1.2s-1.2-.54-1.2-1.2,.54-1.2,1.2-1.2,1.2,.54,1.2,1.2Z" fill="#0a0a0a"/><path d="M20.71,11.04c0,.66-.54,1.2-1.2,1.2s-1.2-.54-1.2-1.2,.54-1.2,1.2-1.2,1.2,.54,1.2,1.2Z" fill="#0a0a0a"/></g><path d="M15,19.61c-2.16,0-4.23-.68-5.98-1.98-.28-.21-.34-.6-.13-.88,.21-.28,.6-.33,.87-.13,1.53,1.13,3.34,1.73,5.24,1.73s3.7-.6,5.24-1.73c.28-.21,.67-.15,.87,.13s.15,.67-.13,.87c-1.75,1.29-3.82,1.98-5.98,1.98Z"/><path d="M15,2c7.17,0,13,5.83,13,13s-5.83,13-13,13S2,22.17,2,15,7.83,2,15,2m0-1C7.27,1,1,7.27,1,15s6.27,14,14,14,14-6.27,14-14S22.73,1,15,1h0Z" fill="#5bb782"/></svg>

Before

Width:  |  Height:  |  Size: 552 B

After

Width:  |  Height:  |  Size: 981 B

View File

@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><circle cx="15" cy="15" r="14" fill="#fd945a"/><path d="M11.39,10.4c0,.66-.54,1.2-1.2,1.2s-1.2-.54-1.2-1.2,.54-1.2,1.2-1.2,1.2,.54,1.2,1.2Z" fill="#0a0a0a"/><path d="M19.96,11.48c0,.66-.54,1.2-1.2,1.2s-1.2-.54-1.2-1.2,.54-1.2,1.2-1.2,1.2,.54,1.2,1.2Z" fill="#0a0a0a"/><path d="M4.3,17.41c-.67,.65-.41,1.67-.29,2.48,.16,1.24-.08,2.53-.67,3.63-.71,1.51,.28,3.61,1.46,4.69,1.85,1.6,4.01,1.15,5.99,.61,.48-.13,.99-.29,1.31-.67s.3-1.08-.15-1.29c.67-.12,.83-1.1,.26-1.45,.54-.35,.55-1.24,.02-1.61,1.15-.2,2.43-.57,3.73-.99,2.07-.68,1.2-2.28,.07-2.13-2.57,.34-5.04,.62-7.63,.92-.52,.06-.98-.3-1.08-.81-.2-1.02,0-2.21-.71-3.05-.56-.66-1.68-.92-2.31-.34Z" fill="#fd945a" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.25"/><path d="M11.62,16.76l2.35,.33" fill="#fd945a" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.25"/></svg> <?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 30 30"><defs><clipPath id="b"><circle cx="15" cy="15" r="14" fill="none"/></clipPath></defs><circle cx="15" cy="15" r="14" fill="#df7f49"/><g clip-path="url(#b)"><ellipse cx="15" cy="13.85" rx="12.85" ry="12.22" fill="#fe955a"/></g><path d="M15,2c7.17,0,13,5.83,13,13s-5.83,13-13,13S2,22.17,2,15,7.83,2,15,2m0-1C7.27,1,1,7.27,1,15s6.27,14,14,14,14-6.27,14-14S22.73,1,15,1h0Z" fill="#df7f49"/><path d="M11.39,10.4c0,.66-.54,1.2-1.2,1.2s-1.2-.54-1.2-1.2,.54-1.2,1.2-1.2,1.2,.54,1.2,1.2Z" fill="#0a0a0a"/><path d="M19.96,11.48c0,.66-.54,1.2-1.2,1.2s-1.2-.54-1.2-1.2,.54-1.2,1.2-1.2,1.2,.54,1.2,1.2Z" fill="#0a0a0a"/><g><path d="M4.3,17.41c-.67,.65-.41,1.67-.29,2.48,.16,1.24-.08,2.53-.67,3.63-.71,1.51,.28,3.61,1.46,4.69,1.85,1.6,4.01,1.15,5.99,.61,.48-.13,.99-.29,1.31-.67s.3-1.08-.15-1.29c.67-.12,.83-1.1,.26-1.45,.54-.35,.55-1.24,.02-1.61,1.15-.2,2.43-.57,3.73-.99,2.07-.68,1.2-2.28,.07-2.13-2.57,.34-5.04,.62-7.63,.92-.52,.06-.98-.3-1.08-.81-.2-1.02,0-2.21-.71-3.05-.56-.66-1.68-.92-2.31-.34Z" fill="#ffb68c"/><path d="M7.86,29.96c-1.19,0-2.36-.32-3.46-1.27-1.44-1.32-2.44-3.7-1.62-5.43,.54-1.02,.76-2.17,.62-3.29l-.04-.28c-.13-.82-.31-1.94,.52-2.73h0c.38-.36,.89-.53,1.45-.5,.67,.04,1.33,.37,1.76,.88,.62,.74,.69,1.64,.75,2.44,.02,.3,.04,.61,.1,.89,.04,.2,.21,.34,.39,.31l.43-.05c2.43-.28,4.76-.55,7.19-.87,.86-.11,1.67,.43,1.86,1.23,.16,.71-.19,1.64-1.65,2.12-1.13,.37-2.1,.65-2.95,.84,.03,.12,.04,.25,.04,.38,0,.29-.08,.56-.21,.79,.14,.28,.19,.6,.12,.94-.05,.25-.15,.47-.3,.65,.02,.06,.04,.11,.05,.17,.11,.47-.01,.99-.32,1.37-.46,.56-1.16,.76-1.63,.88-1.02,.28-2.07,.52-3.11,.52Zm-3.12-12.09c-.33,.32-.27,.86-.15,1.63l.05,.31c.17,1.38-.09,2.8-.74,4.01-.53,1.13,.26,2.95,1.33,3.93,1.58,1.36,3.46,1,5.41,.47,.41-.11,.8-.23,.99-.47,.09-.1,.09-.29,.05-.34-.25-.12-.38-.37-.35-.64s.25-.48,.52-.53c.06-.01,.08-.12,.09-.13,0-.05,.01-.14-.05-.18-.18-.11-.29-.31-.3-.52s.1-.42,.28-.53c.09-.06,.13-.18,.13-.28,0-.1-.03-.23-.12-.29-.21-.14-.31-.4-.25-.65,.05-.25,.25-.44,.5-.48,.98-.17,2.17-.49,3.64-.97,.62-.2,.86-.48,.82-.65-.03-.15-.23-.29-.47-.26-2.43,.32-4.77,.59-7.21,.87l-.43,.05c-.83,.1-1.61-.48-1.76-1.32-.06-.33-.09-.68-.12-1.03-.05-.7-.09-1.3-.46-1.73-.21-.24-.55-.42-.88-.44-.16,0-.37,.01-.52,.15Z" fill="#df7f49"/></g><g><path d="M11.62,16.76l2.35,.33" fill="#fd945a"/><path d="M13.96,17.71s-.06,0-.09,0l-2.35-.33c-.34-.05-.58-.36-.53-.71s.37-.58,.71-.53l2.35,.33c.34,.05,.58,.36,.53,.71-.04,.31-.31,.54-.62,.54Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 984 B

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -4,6 +4,21 @@
{% block extra_js %} {% block extra_js %}
{{ block.super }} {{ block.super }}
<style>
.title_type_learningcontent {
border-left: 2px solid #41B5FA;
padding-left: 16px !important;
}
.title_type_learningunit {
border-left: 2px solid #41B5FA;
padding-left: 16px !important;
}
</style>
<script defer src="/learnpath/icons/"></script>
<script type="text/javascript"> <script type="text/javascript">
if (document.location.href.endsWith('/')) { if (document.location.href.endsWith('/')) {
// add ?ordering=ord as default ordering // add ?ordering=ord as default ordering

View File

@ -0,0 +1,96 @@
{% load i18n %}
{% load l10n %}
{% load wagtailadmin_tags %}
<table class="listing {% if full_width %}full-width{% endif %} {% block table_classname %}{% endblock %}">
{% if show_ordering_column or show_bulk_actions %}
<col width="10px"/>
{% endif %}
<col/>
{% if show_parent %}
<col/>
{% endif %}
<col width="12%"/>
<col width="12%"/>
<col width="12%"/>
<col width="10%"/>
<thead>
{% block pre_parent_page_headers %}
{% endblock %}
{% if parent_page %}
{% page_permissions parent_page as parent_page_perms %}
<tr class="index {% if not parent_page.live %} unpublished{% endif %}
{% block parent_page_row_classname %}{% endblock %}">
<td class="title"{% if show_ordering_column or show_bulk_actions %} colspan="2"{% endif %}>
{% block parent_page_title %}
{% endblock %}
</td>
<td class="updated" valign="bottom">{% if parent_page.latest_revision_created_at %}
<div class="human-readable-date" title="{{ parent_page.latest_revision_created_at|date:"DATETIME_FORMAT" }}">
{% blocktrans trimmed with time_period=parent_page.latest_revision_created_at|timesince %}{{ time_period }}
ago{% endblocktrans %}</div>{% endif %}</td>
<td class="type" valign="bottom">
{% if not parent_page.is_root %}
{{ parent_page.content_type.model_class.get_verbose_name }}
{% endif %}
</td>
<td class="status" valign="bottom">
{% if not parent_page.is_root %}
{% include "wagtailadmin/shared/page_status_tag.html" with page=parent_page %}
{% endif %}
</td>
<td></td>
</tr>
{% endif %}
{% block post_parent_page_headers %}
{% endblock %}
</thead>
<tbody>
{% if pages %}
{% trans "Select page" as checkbox_aria_label %}
{% for page in pages %}
{% page_permissions page as page_perms %}
<tr {% if ordering == "ord" %}id="page_{{ page.id|unlocalize }}"
data-page-title="{{ page.get_admin_display_title }}"{% endif %}
class="{% if not page.live %}unpublished{% endif %} {% block page_row_classname %}{% endblock %}">
{% if show_ordering_column %}
<td class="ord">{% if orderable and ordering == "ord" %}
<div class="handle icon icon-grip text-replace">{% trans 'Drag' %}</div>{% endif %}</td>
{% elif show_bulk_actions %}
{% include "wagtailadmin/bulk_actions/listing_checkbox_cell.html" with obj_type="page" obj=page aria_labelledby_prefix="page_" aria_labelledby=page.pk|unlocalize aria_labelledby_suffix="_title" %}
{% endif %}
<td id="page_{{ page.pk|unlocalize }}_title" class="title title_type_{{ page.content_type.model }}" valign="top" data-listing-page-title>
{{ page.type }}
{% block page_title %}
{% endblock %}
</td>
{% if show_parent %}
<td class="parent" valign="top">
{% block page_parent_page_title %}
{% with page.get_parent as parent %}
{% if parent %}
<a
href="{% url 'wagtailadmin_explore' parent.id %}">{{ parent.specific_deferred.get_admin_display_title }}</a>
{% endif %}
{% endwith %}
{% endblock %}
</td>
{% endif %}
<td class="updated" valign="top">{% if page.latest_revision_created_at %}
<div class="human-readable-date" title="{{ page.latest_revision_created_at|date:"DATETIME_FORMAT" }}">
{% blocktrans trimmed with time_period=page.latest_revision_created_at|timesince %}{{ time_period }}
ago{% endblocktrans %}</div>{% endif %}</td>
<td class="type" valign="top">{{ page.content_type.model_class.get_verbose_name }}</td>
<td class="status" valign="top">
{% include "wagtailadmin/shared/page_status_tag.html" with page=page %}
</td>
{% block page_navigation %}
{% endblock %}
</tr>
{% endfor %}
{% else %}
{% block no_results %}{% endblock %}
{% endif %}
</tbody>
</table>

View File

@ -0,0 +1,8 @@
{% extends "wagtailadmin/pages/listing/_list_explore.html" %}
{% load i18n wagtailadmin_tags %}
{% block page_title %}
{% include "wagtailadmin/pages/listing/_page_title_explore.html" %}
{% endblock %}

View File

@ -0,0 +1,41 @@
{% load i18n wagtailadmin_tags %}
{# The title field for a page in the page listing, when in 'explore' mode #}
<div class="title-wrapper">
{% if page.is_site_root %}
{% if perms.wagtailcore.add_site or perms.wagtailcore.change_site or perms.wagtailcore.delete_site %}
<a href="{% url 'wagtailsites:index' %}" class="icon icon-site" title="{% trans 'Sites menu' %}"></a>
{% endif %}
{% endif %}
{% if page_perms.can_edit %}
<a href="{% url 'wagtailadmin_pages:edit' page.id %}"
title="{% trans 'Edit this page' %}">
{% if page.get_admin_display_title_html %}
{% autoescape off %}{{ page.get_admin_display_title_html }}{% endautoescape %}
{% else %}
{{ page.get_admin_display_title }}
{% endif %}
</a>
<a href="/cms/pages/124/edit/"
title="Edit this page">
</a>
{% else %}
{{ page.get_admin_display_title }}
{% endif %}
{% if show_locale_labels %}
<span class="status-tag status-tag--label">{{ page.locale.get_display_name }}</span>
{% endif %}
{% block pages_listing_title_extra %}{% endblock pages_listing_title_extra %}
{% include "wagtailadmin/pages/listing/_privacy_indicator.html" with page=page %}
{% include "wagtailadmin/pages/listing/_locked_indicator.html" with page=page %}
</div>
<ul class="actions">
{% page_listing_buttons page page_perms %}
</ul>