Merge branch 'develop'

This commit is contained in:
Christian Cueni 2023-02-13 09:35:04 +01:00
commit 442e04dfe3
104 changed files with 2044 additions and 312 deletions

View File

@ -19,6 +19,7 @@ e2e: &e2e
- source ./env/bitbucket/prepare_for_test.sh - source ./env/bitbucket/prepare_for_test.sh
- npm run build - npm run build
- source vbvvenv/bin/activate - source vbvvenv/bin/activate
- pip install -r server/requirements/requirements-dev.txt
- ./prepare_server_cypress.sh --start-background - ./prepare_server_cypress.sh --start-background
- npm run cypress:ci - npm run cypress:ci
artifacts: artifacts:
@ -28,7 +29,7 @@ e2e: &e2e
pipelines: pipelines:
default: default:
- step: - step:
name: install cypress dependencies name: install dependencies
services: services:
- postgres - postgres
caches: caches:
@ -75,7 +76,7 @@ pipelines:
- python -m venv vbvvenv - python -m venv vbvvenv
- source vbvvenv/bin/activate - source vbvvenv/bin/activate
- pip install -r server/requirements/requirements-dev.txt - pip install -r server/requirements/requirements-dev.txt
- git-crypt status -e | sort > git-crypt-encrypted-files-check.txt && diff git-crypt-encrypted-files.txt git-crypt-encrypted-files-check.txt - git-crypt status -e | sort > git-crypt-encrypted-files-check.txt && diff -w git-crypt-encrypted-files.txt git-crypt-encrypted-files-check.txt
- trufflehog --exclude_paths trufflehog-exclude-patterns.txt --allow trufflehog-allow.json --entropy=True --max_depth=100 . - trufflehog --exclude_paths trufflehog-exclude-patterns.txt --allow trufflehog-allow.json --entropy=True --max_depth=100 .
- ufmt check server - ufmt check server
- step: - step:

View File

@ -14,6 +14,7 @@
"@sentry/vue": "^7.20.0", "@sentry/vue": "^7.20.0",
"@urql/vue": "^1.0.2", "@urql/vue": "^1.0.2",
"d3": "^7.6.1", "d3": "^7.6.1",
"dayjs": "^1.11.7",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"loglevel": "^1.8.0", "loglevel": "^1.8.0",
@ -5467,6 +5468,11 @@
"integrity": "sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==", "integrity": "sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==",
"dev": true "dev": true
}, },
"node_modules/dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"node_modules/de-indent": { "node_modules/de-indent": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@ -15934,6 +15940,11 @@
"integrity": "sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==", "integrity": "sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==",
"dev": true "dev": true
}, },
"dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"de-indent": { "de-indent": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",

View File

@ -21,6 +21,7 @@
"@sentry/vue": "^7.20.0", "@sentry/vue": "^7.20.0",
"@urql/vue": "^1.0.2", "@urql/vue": "^1.0.2",
"d3": "^7.6.1", "d3": "^7.6.1",
"dayjs": "^1.11.7",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"loglevel": "^1.8.0", "loglevel": "^1.8.0",

View File

@ -1,13 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { loadLocaleMessages, setI18nLanguage } from "@/i18n"; import type { availableLanguages } from "@/stores/user";
import { useUserStore } from "@/stores/user";
import * as log from "loglevel"; import * as log from "loglevel";
log.debug("AppFooter created"); log.debug("AppFooter created");
const userStore = useUserStore();
async function changeLocale(event: Event) { async function changeLocale(event: Event) {
const target = event.target as HTMLSelectElement; const target = event.target as HTMLSelectElement;
await loadLocaleMessages(target.value); userStore.setUserLanguages(target.value as availableLanguages);
setI18nLanguage(target.value);
} }
</script> </script>
@ -22,9 +24,16 @@ async function changeLocale(event: Event) {
<div class="lg:ml-8">Deutsch</div> <div class="lg:ml-8">Deutsch</div>
<!--div class="locale-changer"> <!--div class="locale-changer">
<select @change="changeLocale($event)"> <select @change="changeLocale($event)">
<option v-for="locale in ['de', 'fr']" :key="`locale-${locale}`" :value="locale">{{ locale }}</option> <option
v-for="locale in ['de', 'fr']"
:key="`locale-${locale}`"
:value="locale"
:selected="locale === userStore.language"
>
{{ locale }}
</option>
</select> </select>
</div--> </div -->
<div class="lg:ml-8">{{ $t("footer.contact") }}</div> <div class="lg:ml-8">{{ $t("footer.contact") }}</div>
</footer> </footer>
</template> </template>

View File

@ -157,12 +157,9 @@ const MAX_STEPS = 12;
const sendFeedbackMutation = graphql(` const sendFeedbackMutation = graphql(`
mutation SendFeedbackMutation($input: SendFeedbackInput!) { mutation SendFeedbackMutation($input: SendFeedbackInput!) {
sendFeedback(input: $input) { sendFeedback(input: $input) {
id feedbackResponse {
satisfaction id
goalAttainment }
proficiency
receivedMaterials
materialsRating
errors { errors {
field field
messages messages
@ -205,17 +202,19 @@ const sendFeedback = () => {
return; return;
} }
const input: SendFeedbackInput = reactive({ const input: SendFeedbackInput = reactive({
materialsRating, data: {
courseNegativeFeedback, materials_rating: materialsRating,
coursePositiveFeedback, course_negative_feedback: courseNegativeFeedback,
goalAttainment, course_positive_feedback: coursePositiveFeedback,
instructorCompetence, goald_attainment: goalAttainment,
instructorRespect, instructor_competence: instructorCompetence,
instructorOpenFeedback, instructor_respect: instructorRespect,
satisfaction, instructor_open_feedback: instructorOpenFeedback,
proficiency, satisfaction,
receivedMaterials, proficiency,
wouldRecommend, received_materials: receivedMaterials,
would_recommend: wouldRecommend,
},
page: props.page.translation_key, page: props.page.translation_key,
courseSession: courseSession.id, courseSession: courseSession.id,
}); });

View File

@ -4,9 +4,12 @@ import log from "loglevel";
import IconLogout from "@/components/icons/IconLogout.vue"; import IconLogout from "@/components/icons/IconLogout.vue";
import IconSettings from "@/components/icons/IconSettings.vue"; import IconSettings from "@/components/icons/IconSettings.vue";
import MobileMenu from "@/components/MobileMenu.vue"; import MobileMenu from "@/components/MobileMenu.vue";
import NotificationPopover from "@/components/notifications/NotificationPopover.vue";
import NotificationPopoverContent from "@/components/notifications/NotificationPopoverContent.vue";
import ItDropdown from "@/components/ui/ItDropdown.vue"; import ItDropdown from "@/components/ui/ItDropdown.vue";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useNotificationsStore } from "@/stores/notifications";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import type { DropdownListItem } from "@/types"; import type { DropdownListItem } from "@/types";
import type { Component } from "vue"; import type { Component } from "vue";
@ -14,7 +17,7 @@ import { onMounted, reactive } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
type DropdownActions = "logout" | "settings"; type DropdownActions = "logout" | "settings" | "profile";
interface DropdownData { interface DropdownData {
action: DropdownActions; action: DropdownActions;
@ -27,6 +30,7 @@ const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const appStore = useAppStore(); const appStore = useAppStore();
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
const notificationsStore = useNotificationsStore();
const { t } = useI18n(); const { t } = useI18n();
const state = reactive({ showMenu: false }); const state = reactive({ showMenu: false });
@ -60,9 +64,12 @@ function inMediaLibrary() {
function handleDropdownSelect(data: DropdownData) { function handleDropdownSelect(data: DropdownData) {
switch (data.action) { switch (data.action) {
case "settings": case "profile":
router.push("/profile"); router.push("/profile");
break; break;
case "settings":
router.push("/settings");
break;
case "logout": case "logout":
userStore.handleLogout(); userStore.handleLogout();
break; break;
@ -85,7 +92,14 @@ onMounted(() => {
const profileDropdownData: DropdownListItem[] = [ const profileDropdownData: DropdownListItem[] = [
{ {
title: t("mainNavigation.settings"), title: t("mainNavigation.profile"),
icon: IconSettings as Component,
data: {
action: "profile",
},
},
{
title: t("general.settings"),
icon: IconSettings as Component, icon: IconSettings as Component,
data: { data: {
action: "settings", action: "settings",
@ -127,14 +141,23 @@ const profileDropdownData: DropdownListItem[] = [
</div> </div>
<div class="flex items-center lg:hidden"> <div class="flex items-center lg:hidden">
<router-link <div v-if="userStore.loggedIn" class="mr-6 flex flex-row items-center">
v-if="userStore.loggedIn" <NotificationPopover>
to="/messages" <template #toggleButtonContent>
class="nav-item flex flex-row items-center" <div class="nav-item flex">
data-cy="messages-link" <it-icon-notification class="h-6 w-6" />
> <div
<it-icon-bell class="mr-6 h-6 w-6" /> v-if="notificationsStore.hasUnread"
</router-link> aria-label="unread notifications"
class="mt-1 h-1.5 w-1.5 rounded-full bg-sky-500"
/>
</div>
</template>
<template #popoverContent>
<NotificationPopoverContent />
</template>
</NotificationPopover>
</div>
<router-link <router-link
v-if="userStore.loggedIn" v-if="userStore.loggedIn"
to="/messages" to="/messages"
@ -218,13 +241,23 @@ const profileDropdownData: DropdownListItem[] = [
> >
{{ $t("mediaLibrary.title") }} {{ $t("mediaLibrary.title") }}
</router-link> </router-link>
<router-link <div v-if="userStore.loggedIn" class="mr-6 flex items-center">
to="/messages" <NotificationPopover>
class="nav-item flex flex-row items-center" <template #toggleButtonContent>
data-cy="messages-link" <div class="nav-item flex">
> <it-icon-notification class="h-6 w-6" />
<it-icon-bell class="mr-6 h-6 w-6" /> <div
</router-link> v-if="notificationsStore.hasUnread"
aria-label="unread notifications"
class="mt-1 h-1.5 w-1.5 rounded-full bg-sky-500"
/>
</div>
</template>
<template #popoverContent>
<NotificationPopoverContent />
</template>
</NotificationPopover>
</div>
<router-link <router-link
to="/messages" to="/messages"
class="nav-item flex flex-row items-center" class="nav-item flex flex-row items-center"

View File

@ -43,10 +43,10 @@ const clickLink = (to: string | undefined) => {
<h3>{{ userStore.first_name }} {{ userStore.last_name }}</h3> <h3>{{ userStore.first_name }} {{ userStore.last_name }}</h3>
<button <button
class="mt-2 inline-block flex items-center" class="mt-2 inline-block flex items-center"
@click="clickLink('/profile')" @click="clickLink('/settings')"
> >
<IconSettings class="inline-block" /> <IconSettings class="inline-block" />
<span class="ml-3">{{ $t("mainNavigation.settings") }}</span> <span class="ml-3">{{ $t("general.settings") }}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,135 @@
<script setup lang="ts">
import * as d3 from "d3";
import * as log from "loglevel";
import { computed, onMounted } from "vue";
// @ts-ignore
import colors from "@/colors.json";
export type CircleSectorProgress = "none" | "in_progress" | "finished";
export interface CircleSectorData {
progress: CircleSectorProgress;
}
const props = defineProps<{
sectors: CircleSectorData[];
}>();
onMounted(async () => {
log.debug("LearningPathCircle mounted");
render();
});
interface CircleSector extends d3.PieArcDatum<number> {
arrowStartAngle: number;
arrowEndAngle: number;
progress: CircleSectorProgress;
}
const pieData = computed(() => {
const pieWeights = new Array(Math.max(props.sectors.length, 1)).fill(1);
const pieGenerator = d3.pie();
const angles = pieGenerator(pieWeights);
return angles
.map((angle) => {
// Rotate the circle by PI (180 degrees) normally 0 = 12'o clock, now start at 6 o clock
angle.startAngle += Math.PI;
angle.endAngle += Math.PI;
return Object.assign(
{
startAngle: angle.startAngle,
endAngle: angle.endAngle,
arrowStartAngle: angle.endAngle + (angle.startAngle - angle.endAngle) / 2,
arrowEndAngle: angle.startAngle + (angle.startAngle - angle.endAngle) / 2,
progress: props.sectors[angle.index].progress,
},
angle
);
})
.reverse() as CircleSector[];
});
const width = 450;
const height = 450;
const radius = Math.min(width, height) / 2.4;
function getColor(sector: CircleSector) {
let color = colors.gray[300];
if ("in_progress" === sector.progress) {
color = colors.sky[500];
}
if ("finished" === sector.progress) {
color = colors.green[500];
}
return color;
}
function render() {
const svg = d3.select(".circle-visualization");
// Clean svg before adding new stuff.
svg.selectAll("*").remove();
if (pieData.value) {
const arrowStrokeWidth = 2;
// Append marker as definition to the svg
svg
.attr("viewBox", `0 0 ${width} ${height}`)
.append("svg:defs")
.append("svg:marker")
.attr("id", "triangle")
.attr("refX", 11)
.attr("refY", 11)
.attr("markerWidth", 20)
.attr("markerHeight", 20)
.attr("markerUnits", "userSpaceOnUse")
.attr("orient", "auto")
.append("path")
.attr("d", "M -1 0 l 10 0 M 0 -1 l 0 10")
.attr("transform", "rotate(-90, 10, 0)")
.attr("stroke-width", arrowStrokeWidth)
.attr("stroke", colors.gray[500]);
const g = svg
.append("g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
// Generate the pie diagram wedge
const wedgeGenerator = d3
.arc()
.innerRadius(radius / 2.5)
.padAngle(12 / 360)
.outerRadius(radius);
const learningSequences = g
.selectAll(".learningSegmentArc")
.data(pieData.value)
.enter()
.append("g")
.attr("class", "learningSegmentArc");
learningSequences
.transition()
.duration(1)
.attr("fill", (d) => {
return getColor(d);
});
// @ts-ignore
learningSequences.append("path").attr("d", wedgeGenerator);
}
return svg;
}
</script>
<template>
<div class="svg-container h-full content-center">
<pre hidden>{{ pieData }}</pre>
<pre hidden>{{ render() }}</pre>
<svg class="circle-visualization h-full">
<circle :cx="width / 2" :cy="height / 2" :r="radius" :color="colors.gray[300]" />
<circle :cx="width / 2" :cy="height / 2" :r="radius / 2.5" color="white" />
</svg>
</div>
</template>

View File

@ -126,7 +126,10 @@ const learningSequenceBorderClass = computed(() => {
</div> </div>
<ItCheckbox <ItCheckbox
v-else v-else
:checked="learningContent.completion_status === 'success'" :checkbox_item="{
value: learningContent.completion_status,
checked: learningContent.completion_status === 'success',
}"
:data-cy="`${learningContent.slug}-checkbox`" :data-cy="`${learningContent.slug}-checkbox`"
@toggle="toggleCompleted(learningContent)" @toggle="toggleCompleted(learningContent)"
/> />

View File

@ -0,0 +1,98 @@
<script setup lang="ts">
import router from "@/router";
import { useNotificationsStore } from "@/stores/notifications";
import type { Notification } from "@/types";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { onMounted, ref, watch } from "vue";
dayjs.extend(relativeTime);
const notificationsStore = useNotificationsStore();
const props = defineProps<{
numNotificationsToShow: number;
}>();
const state = ref({ notifications: [] as Notification[] });
async function loadNotifications() {
state.value.notifications = await notificationsStore.loadNotifications(
props.numNotificationsToShow
);
}
onMounted(async () => {
await loadNotifications();
});
watch(
() => props.numNotificationsToShow,
async () => {
await loadNotifications();
}
);
function onNotificationClick(notification: Notification) {
if (notification.target_url) {
router.push(notification.target_url);
}
}
</script>
<template>
<div
v-if="0 === state.notifications.length"
class="mt-14 mb-14 text-center text-black"
data-cy="no-notifications"
>
{{ $t("notifications.no_notifications") }}
</div>
<li
v-for="(notification, index) in state.notifications"
:key="notification.id"
:data-cy="`notification-idx-${index}`"
class="flex flex-row justify-between border-b border-gray-500 py-4 leading-[45px] last:border-0"
>
<div class="flex flex-row">
<img
v-if="notification.notification_type === 'USER_INTERACTION'"
alt="Notification icon"
class="mr-2 h-[45px] min-w-[45px] rounded-full"
:src="notification.actor_avatar_url ?? undefined"
/>
<it-icon-vbv
v-else
class="it-icon mr-2 h-[45px] min-w-[45px] rounded-full bg-blue-900"
/>
<button
:to="notification.target_url"
class="mr-2 flex flex-col lg:mr-10"
:disabled="null === notification.target_url"
:data-cy="`notification-target-idx-${index}`"
@click="() => onNotificationClick(notification)"
>
<span
class="text-left text-sm leading-6 text-black lg:text-base"
style="hyphens: none"
>
{{ notification.verb }}
</span>
<span class="flex flex-wrap text-sm leading-6 text-gray-500 lg:text-base">
<span v-if="notification.course">{{ notification.course }} -&nbsp;</span>
<span>
{{ dayjs(notification.timestamp).fromNow() }}
</span>
</span>
</button>
</div>
<div v-if="notification.unread" class="leading-[45px]">
<div class="flex h-[45px] flex-row items-center pl-3" data-cy="unread">
<div class="h-[10px] w-[10px] rounded-full bg-blue-500" />
</div>
</div>
</li>
</template>
<style scoped></style>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue";
</script>
<template>
<Popover>
<PopoverButton data-cy="notification-bell-button">
<slot name="toggleButtonContent"></slot>
</PopoverButton>
<PopoverPanel>
<div
class="absolute right-0 mt-2 bg-white px-4 py-4 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none lg:right-2"
>
<!-- To close the popover withing your content, use the 'PopoverButton'
https://headlessui.com/vue/popover#closing-popovers-manually
-->
<slot name="popoverContent" />
</div>
</PopoverPanel>
</Popover>
</template>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import NotificationList from "@/components/notifications/NotificationList.vue";
import { PopoverButton } from "@headlessui/vue";
</script>
<template>
<div class="pb-2 text-lg text-black">{{ $t("general.notification") }}</div>
<div class="border-t bg-white">
<NotificationList :num-notifications-to-show="4" />
<router-link to="/notifications">
<PopoverButton
class="btn-text inline-flex inline-flex items-center text-blue-900"
data-cy="show-all-notifications"
>
<span>{{ $t("general.showAll") }}</span>
<it-icon-arrow-right />
</PopoverButton>
</router-link>
</div>
</template>

View File

@ -1,17 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CheckboxItem } from "@/components/ui/checkbox.types";
import log from "loglevel"; import log from "loglevel";
interface Props { const props = defineProps<{
checked?: boolean; checkbox_item: CheckboxItem<any>;
disabled?: boolean; disabled?: boolean;
label?: string; }>();
}
const props = withDefaults(defineProps<Props>(), {
checked: false,
disabled: false,
label: undefined,
});
const emit = defineEmits(["toggle"]); const emit = defineEmits(["toggle"]);
const toggle = () => { const toggle = () => {
@ -42,7 +36,7 @@ const input = (e: Event) => {
<label <label
class="cy-checkbox cy-checkbox-checked block flex h-8 items-center bg-contain bg-no-repeat pl-8 disabled:opacity-50" class="cy-checkbox cy-checkbox-checked block flex h-8 items-center bg-contain bg-no-repeat pl-8 disabled:opacity-50"
:class=" :class="
checked checkbox_item.checked
? 'bg-[url(/static/icons/icon-checkbox-checked.svg)] hover:bg-[url(/static/icons/icon-checkbox-checked-hover.svg)]' ? 'bg-[url(/static/icons/icon-checkbox-checked.svg)] hover:bg-[url(/static/icons/icon-checkbox-checked-hover.svg)]'
: 'bg-[url(/static/icons/icon-checkbox-unchecked.svg)] hover:bg-[url(/static/icons/icon-checkbox-unchecked-hover.svg)]' : 'bg-[url(/static/icons/icon-checkbox-unchecked.svg)] hover:bg-[url(/static/icons/icon-checkbox-unchecked-hover.svg)]'
" "
@ -51,18 +45,23 @@ const input = (e: Event) => {
> >
<input <input
ref="checkbox" ref="checkbox"
:checked="checked" :checked="checkbox_item.checked"
:value="checked" :value="checkbox_item.value"
:disabled="disabled" :disabled="disabled"
:data-cy="`it-checkbox-${checkbox_item.value}`"
class="sr-only" class="sr-only"
type="checkbox" type="checkbox"
@keydown="keydown" @keydown="keydown"
@input="input" @input="input"
/> />
<div class="ml-4 flex-col">
<span v-if="label" class="ml-4"> <div v-if="checkbox_item.label">
{{ label }} {{ checkbox_item.label }}
</span> </div>
<div v-if="checkbox_item.subtitle" class="text-gray-900">
{{ checkbox_item.subtitle }}
</div>
</div>
</label> </label>
</div> </div>
</template> </template>

View File

@ -1,29 +1,33 @@
<template>
<div
:model-value="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
>
<h2 class="mb-12 block text-5xl font-bold leading-normal">{{ label }}</h2>
<div class="align-items-start flex flex-col justify-start justify-items-start">
<ItCheckbox
v-for="item in items"
:key="item.value"
:label="item.name"
class="mb-4"
/>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import type { RadioItem } from "@/components/learningPath/feedback.types"; import type { CheckboxItem } from "@/components/ui/checkbox.types";
import ItCheckbox from "@/components/ui/ItCheckbox.vue"; import ItCheckbox from "@/components/ui/ItCheckbox.vue";
defineProps<{ const props = defineProps<{
modelValue: any; items: CheckboxItem<any>[];
items: RadioItem<any>[]; label: string | undefined;
label: string;
}>(); }>();
defineEmits(["update:modelValue"]); const emit = defineEmits(["update:items"]);
function updateItems(itemValue: string) {
const items = props.items.map((item) => {
if (item.value === itemValue) {
item.checked = !item.checked;
}
return item;
});
emit("update:items", items);
}
</script> </script>
<template>
<h3 class="mb-6 block font-bold leading-normal">{{ label }}</h3>
<div class="flex flex-col">
<ItCheckbox
v-for="item in items"
:key="item.value"
:checkbox_item="item"
:class="item.subtitle ? 'mb-6' : 'mb-4'"
@toggle="updateItems(item.value)"
/>
</div>
</template>

View File

@ -0,0 +1,6 @@
export interface CheckboxItem<T> {
value: T;
label?: string;
checked: boolean;
subtitle?: string;
}

View File

@ -3,13 +3,13 @@ import type { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-
import * as types from "./graphql"; import * as types from "./graphql";
const documents = { const documents = {
"\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n id\n satisfaction\n goalAttainment\n proficiency\n receivedMaterials\n materialsRating\n errors {\n field\n messages\n }\n }\n }\n": "\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n feedbackResponse {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n":
types.SendFeedbackMutationDocument, types.SendFeedbackMutationDocument,
}; };
export function graphql( export function graphql(
source: "\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n id\n satisfaction\n goalAttainment\n proficiency\n receivedMaterials\n materialsRating\n errors {\n field\n messages\n }\n }\n }\n" source: "\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n feedbackResponse {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n"
): typeof documents["\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n id\n satisfaction\n goalAttainment\n proficiency\n receivedMaterials\n materialsRating\n errors {\n field\n messages\n }\n }\n }\n"]; ): typeof documents["\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n feedbackResponse {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n"];
export function graphql(source: string): unknown; export function graphql(source: string): unknown;
export function graphql(source: string) { export function graphql(source: string) {

View File

@ -17,6 +17,7 @@ export type Scalars = {
Int: number; Int: number;
Float: number; Float: number;
DateTime: any; DateTime: any;
GenericScalar: any;
JSONString: any; JSONString: any;
PositiveInt: any; PositiveInt: any;
UUID: any; UUID: any;
@ -152,6 +153,11 @@ export type CircleSiblingsArgs = {
searchQuery?: InputMaybe<Scalars["String"]>; searchQuery?: InputMaybe<Scalars["String"]>;
}; };
export type CircleDocument = {
__typename?: "CircleDocument";
id?: Maybe<Scalars["ID"]>;
};
export type CollectionObjectType = { export type CollectionObjectType = {
__typename?: "CollectionObjectType"; __typename?: "CollectionObjectType";
ancestors: Array<Maybe<CollectionObjectType>>; ancestors: Array<Maybe<CollectionObjectType>>;
@ -520,6 +526,14 @@ export type ErrorType = {
messages: Array<Scalars["String"]>; messages: Array<Scalars["String"]>;
}; };
export type FeedbackResponse = Node & {
__typename?: "FeedbackResponse";
circle: Circle;
courseSession: CourseSession;
data?: Maybe<Scalars["GenericScalar"]>;
id: Scalars["ID"];
};
export type FloatBlock = StreamFieldInterface & { export type FloatBlock = StreamFieldInterface & {
__typename?: "FloatBlock"; __typename?: "FloatBlock";
blockType: Scalars["String"]; blockType: Scalars["String"];
@ -1170,6 +1184,10 @@ export type MutationSendFeedbackArgs = {
input: SendFeedbackInput; input: SendFeedbackInput;
}; };
export type Node = {
id: Scalars["ID"];
};
export type Page = PageInterface & { export type Page = PageInterface & {
__typename?: "Page"; __typename?: "Page";
aliasOf?: Maybe<Page>; aliasOf?: Maybe<Page>;
@ -1581,6 +1599,7 @@ export type RichTextBlock = StreamFieldInterface & {
export type Search = export type Search =
| Circle | Circle
| CircleDocument
| CompetencePage | CompetencePage
| CompetenceProfilePage | CompetenceProfilePage
| Course | Course
@ -1609,37 +1628,16 @@ export type SecurityRequestResponseLog = {
export type SendFeedbackInput = { export type SendFeedbackInput = {
clientMutationId?: InputMaybe<Scalars["String"]>; clientMutationId?: InputMaybe<Scalars["String"]>;
courseNegativeFeedback?: InputMaybe<Scalars["String"]>; courseSession: Scalars["Int"];
coursePositiveFeedback?: InputMaybe<Scalars["String"]>; data?: InputMaybe<Scalars["GenericScalar"]>;
goalAttainment?: InputMaybe<Scalars["Int"]>;
id?: InputMaybe<Scalars["Int"]>;
instructorCompetence?: InputMaybe<Scalars["Int"]>;
instructorOpenFeedback?: InputMaybe<Scalars["String"]>;
instructorRespect?: InputMaybe<Scalars["Int"]>;
materialsRating?: InputMaybe<Scalars["Int"]>;
page: Scalars["String"]; page: Scalars["String"];
proficiency?: InputMaybe<Scalars["Int"]>;
receivedMaterials?: InputMaybe<Scalars["Boolean"]>;
satisfaction?: InputMaybe<Scalars["Int"]>;
wouldRecommend?: InputMaybe<Scalars["Boolean"]>;
}; };
export type SendFeedbackPayload = { export type SendFeedbackPayload = {
__typename?: "SendFeedbackPayload"; __typename?: "SendFeedbackPayload";
clientMutationId?: Maybe<Scalars["String"]>; clientMutationId?: Maybe<Scalars["String"]>;
courseNegativeFeedback?: Maybe<Scalars["String"]>;
coursePositiveFeedback?: Maybe<Scalars["String"]>;
errors?: Maybe<Array<Maybe<ErrorType>>>; errors?: Maybe<Array<Maybe<ErrorType>>>;
goalAttainment?: Maybe<Scalars["Int"]>; feedbackResponse?: Maybe<FeedbackResponse>;
id?: Maybe<Scalars["Int"]>;
instructorCompetence?: Maybe<Scalars["Int"]>;
instructorOpenFeedback?: Maybe<Scalars["String"]>;
instructorRespect?: Maybe<Scalars["Int"]>;
materialsRating?: Maybe<Scalars["Int"]>;
proficiency?: Maybe<Scalars["Int"]>;
receivedMaterials?: Maybe<Scalars["Boolean"]>;
satisfaction?: Maybe<Scalars["Int"]>;
wouldRecommend?: Maybe<Scalars["Boolean"]>;
}; };
export type SiteObjectType = { export type SiteObjectType = {
@ -1851,12 +1849,7 @@ export type SendFeedbackMutationMutation = {
__typename?: "Mutation"; __typename?: "Mutation";
sendFeedback?: { sendFeedback?: {
__typename?: "SendFeedbackPayload"; __typename?: "SendFeedbackPayload";
id?: number | null; feedbackResponse?: { __typename?: "FeedbackResponse"; id: string } | null;
satisfaction?: number | null;
goalAttainment?: number | null;
proficiency?: number | null;
receivedMaterials?: boolean | null;
materialsRating?: number | null;
errors?: Array<{ errors?: Array<{
__typename?: "ErrorType"; __typename?: "ErrorType";
field: string; field: string;
@ -1901,12 +1894,16 @@ export const SendFeedbackMutationDocument = {
selectionSet: { selectionSet: {
kind: "SelectionSet", kind: "SelectionSet",
selections: [ selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } }, {
{ kind: "Field", name: { kind: "Name", value: "satisfaction" } }, kind: "Field",
{ kind: "Field", name: { kind: "Name", value: "goalAttainment" } }, name: { kind: "Name", value: "feedbackResponse" },
{ kind: "Field", name: { kind: "Name", value: "proficiency" } }, selectionSet: {
{ kind: "Field", name: { kind: "Name", value: "receivedMaterials" } }, kind: "SelectionSet",
{ kind: "Field", name: { kind: "Name", value: "materialsRating" } }, selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
],
},
},
{ {
kind: "Field", kind: "Field",
name: { kind: "Name", value: "errors" }, name: { kind: "Name", value: "errors" },

View File

@ -1,3 +1,4 @@
import dayjs from "dayjs";
import { nextTick } from "vue"; import { nextTick } from "vue";
import { createI18n } from "vue-i18n"; import { createI18n } from "vue-i18n";
@ -5,9 +6,12 @@ import { createI18n } from "vue-i18n";
export const SUPPORT_LOCALES = ["de", "fr", "it"]; export const SUPPORT_LOCALES = ["de", "fr", "it"];
let i18n: any = null; let i18n: any = null;
export function setupI18n(options = { locale: "de", legacy: false }) { export function setupI18n(
options = { locale: "de", legacy: false, fallbackLocale: "de" }
) {
i18n = createI18n(options); i18n = createI18n(options);
setI18nLanguage(options.locale); setI18nLanguage(options.locale);
dayjs.locale(options.locale);
return i18n; return i18n;
} }

View File

@ -8,6 +8,7 @@
"back": "zurück", "back": "zurück",
"backCapitalized": "@.capitalize:general.back", "backCapitalized": "@.capitalize:general.back",
"save": "Speichern", "save": "Speichern",
"send": "Senden",
"learningUnit": "Lerneinheit", "learningUnit": "Lerneinheit",
"learningPath": "Lernpfad", "learningPath": "Lernpfad",
"learningSequence": "Lernsequenz", "learningSequence": "Lernsequenz",
@ -22,11 +23,13 @@
"profileLink": "Profil anzeigen", "profileLink": "Profil anzeigen",
"shop": "Shop", "shop": "Shop",
"yes": "Ja", "yes": "Ja",
"no": "Nein" "no": "Nein",
"showAll": "Alle anschauen",
"settings": "Kontoeinstellungen"
}, },
"mainNavigation": { "mainNavigation": {
"logout": "Abmelden", "logout": "Abmelden",
"settings": "Kontoeinstellungen" "profile": "Profil"
}, },
"dashboard": { "dashboard": {
"welcome": "Willkommen, {name}" "welcome": "Willkommen, {name}"
@ -83,7 +86,6 @@
"competences": "Kompetenzen", "competences": "Kompetenzen",
"title": "KompetenzNavi", "title": "KompetenzNavi",
"lastImprovements": "Letzte verbesserte Kompetenzen", "lastImprovements": "Letzte verbesserte Kompetenzen",
"showAll": "Alle anschauen",
"assessment": "Einschätzungen", "assessment": "Einschätzungen",
"notAssessed": "Nicht eingeschätzt", "notAssessed": "Nicht eingeschätzt",
"assessAgain": "Sich nochmals einschätzen" "assessAgain": "Sich nochmals einschätzen"
@ -111,7 +113,9 @@
"feedbacksDone": "Abgeschickte Feedbacks von Teilnehmer.", "feedbacksDone": "Abgeschickte Feedbacks von Teilnehmer.",
"examsDone": "Abgelegte Prüfungen von Teilnehmer.", "examsDone": "Abgelegte Prüfungen von Teilnehmer.",
"progress": "Fortschritt", "progress": "Fortschritt",
"profileLink": "Profil anzeigen" "profileLink": "Profil anzeigen",
"notifyTaskDescription": "Teilnehmer benachrichtigen",
"notifyTask": "Benachrichtigen"
}, },
"messages": { "messages": {
"sendMessage": "Nachricht schreiben" "sendMessage": "Nachricht schreiben"
@ -148,6 +152,13 @@
"answers": "Antworten", "answers": "Antworten",
"noFeedbacks": "Es wurden noch keine Feedbacks abgegeben" "noFeedbacks": "Es wurden noch keine Feedbacks abgegeben"
}, },
"notifications": {
"load_more": "Mehr laden",
"no_notifications": "Du hast derzeit keine Benachrichtigungen"
},
"settings": {
"emailNotifications": "Email Benachrichtigungen"
},
"constants": { "constants": {
"yes": "Ja", "yes": "Ja",
"no": "Nein", "no": "Nein",

View File

@ -18,7 +18,10 @@ declare module "pinia" {
} }
} }
if (window.location.href.indexOf("localhost") >= 0) { if (
window.location.href.indexOf("localhost") >= 0 ||
window.location.href.indexOf("127.0.0.1") >= 0
) {
log.setLevel("trace"); log.setLevel("trace");
} else { } else {
log.setLevel("warn"); log.setLevel("warn");

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import NotificationList from "@/components/notifications/NotificationList.vue";
import { useNotificationsStore } from "@/stores/notifications";
import { ref } from "vue";
const notificationsStore = useNotificationsStore();
const numNotificationsToShow = ref(7);
async function loadAdditionalNotifications() {
numNotificationsToShow.value *= 2;
}
</script>
<template>
<div class="bg-gray-200">
<div class="container-large px-8 py-8">
<header class="mb-6">
<h1>{{ $t("general.notification") }}</h1>
</header>
<main>
<div class="bg-white px-4 py-4">
<NotificationList :num-notifications-to-show="numNotificationsToShow" />
<button
v-if="notificationsStore.allCount > numNotificationsToShow"
class="mt-4 underline"
data-cy="load-more-notifications"
@click="loadAdditionalNotifications()"
>
{{ $t("notifications.load_more") }}
</button>
</div>
</main>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import ItCheckboxGroup from "@/components/ui/ItCheckboxGroup.vue";
import { itGet, itPost } from "@/fetchHelpers";
import { onMounted, ref, watch } from "vue";
// TODO: make translation
const items = ref([
{
label: "Aktivität",
subtitle: "z.B. “Deiner Trainer hat dir Feedback zu einem Auftrag gegeben”",
value: "USER_INTERACTION",
checked: false,
},
{
label: "Fortschritt",
subtitle: "z.B. “Du kannst dich jetzt für die Prüfung anmelden.”",
value: "PROGRESS",
checked: false,
},
{
label: "Information",
subtitle:
"z.B. “Am 20.12. zwischen 08:00 und 12:00 Uhr werden wir Wartungsarbeiten durchführen.”",
value: "INFORMATION",
checked: false,
},
]);
watch(items, async (items) => {
try {
await itPost(
"/api/notify/email_notification_settings/",
items.filter((item) => item.checked).map((item) => item.value)
);
console.debug("Updated email notification settings");
} catch (e) {
console.error(`Could not update email notification settings: ${e}`);
}
});
onMounted(async () => {
const response = await itGet("/api/notify/email_notification_settings/");
items.value = items.value.map((item) => {
item.checked = response.includes(item.value);
return item;
});
});
</script>
<template>
<div class="bg-gray-200">
<div class="container-large">
<header class="mt-12 mb-8">
<h1>{{ $t("general.settings") }}</h1>
</header>
<main>
<div class="bg-white p-6">
<ItCheckboxGroup
:items="items"
:label="$t('settings.emailNotifications')"
@update:items="items = $event"
></ItCheckboxGroup>
</div>
</main>
</div>
</div>
</template>
<style scoped></style>

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import IconLogout from "@/components/icons/IconLogout.vue"; import IconLogout from "@/components/icons/IconLogout.vue";
import IconSettings from "@/components/icons/IconSettings.vue"; import IconSettings from "@/components/icons/IconSettings.vue";
import LearningPathCircle from "@/components/learningPath/LearningPathCircle.vue";
import HorizontalBarChart from "@/components/ui/HorizontalBarChart.vue"; import HorizontalBarChart from "@/components/ui/HorizontalBarChart.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue"; import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItCheckboxGroup from "@/components/ui/ItCheckboxGroup.vue"; import ItCheckboxGroup from "@/components/ui/ItCheckboxGroup.vue";
@ -137,6 +138,11 @@ function log(data: any) {
<it-icon-message class="it-icon" /> <it-icon-message class="it-icon" />
</div> </div>
<div class="inline-flex flex-col">
notification
<it-icon-notification class="it-icon" />
</div>
<div class="inline-flex flex-col"> <div class="inline-flex flex-col">
arrow-up arrow-up
<it-icon-arrow-up /> <it-icon-arrow-up />
@ -407,14 +413,56 @@ function log(data: any) {
<h2 class="mt-8 mb-8">Checkbox</h2> <h2 class="mt-8 mb-8">Checkbox</h2>
<ItCheckbox <ItCheckbox
:checked="state.checkboxValue" :checkbox_item="{
:disabled="false" subtitle: 'Subtitle',
label: 'Label',
value: 'value',
checked: false,
}"
@toggle="state.checkboxValue = !state.checkboxValue" @toggle="state.checkboxValue = !state.checkboxValue"
> >
Label Label
</ItCheckbox> </ItCheckbox>
<ItCheckbox disabled class="mt-4">Disabled</ItCheckbox> <br />
<ItCheckbox
:disabled="true"
:checkbox_item="{
subtitle: 'checked disabled',
label: 'Label',
value: 'value',
checked: true,
}"
class="mt-6"
>
Disabled
</ItCheckbox>
<h2 class="mt-8 mb-8">Checkbox Group</h2>
<ItCheckboxGroup
label="Label"
:items="[
{
value: 'checkbox1',
label: 'Label 1',
subtitle: 'Subtitle 1',
checked: false,
},
{
value: 'checkbox2',
label: 'Label 2',
subtitle: 'Subtitle 2',
checked: false,
},
{
value: 'checkbox3',
label: 'Label 3',
checked: false,
},
]"
/>
<h2 class="mt-8 mb-8">Dropdown</h2> <h2 class="mt-8 mb-8">Dropdown</h2>
@ -429,12 +477,7 @@ function log(data: any) {
</ItDropdown> </ItDropdown>
</div> </div>
<ItTextarea v-model="textValue" label="Hallo Velo" class="mb-8" /> <ItTextarea v-model="textValue" label="Hallo Velo" class="mb-8" />
<ItCheckboxGroup
v-model="sourceValues"
:label="sourceLabel"
:items="sourceItems"
class="mb-8"
/>
<ItRadioGroup <ItRadioGroup
v-model="satisfaction" v-model="satisfaction"
:label="satisfactionText" :label="satisfactionText"
@ -454,6 +497,26 @@ function log(data: any) {
/> />
<HorizontalBarChart title="Frage X" text="Fragentext" :items="barChartItems" /> <HorizontalBarChart title="Frage X" text="Fragentext" :items="barChartItems" />
</div> </div>
<h2 class="mt-8 mb-8">LearningPathCircle</h2>
<LearningPathCircle
class="h-48 w-48"
:sectors="[
{
progress: 'finished',
},
{
progress: 'finished',
},
{
progress: 'in_progress',
},
{
progress: 'none',
},
]"
></LearningPathCircle>
</main> </main>
</template> </template>

View File

@ -151,9 +151,9 @@ function setActiveClasses(translationKey: string) {
> >
<ul class="w-full"> <ul class="w-full">
<li <li
class="flex h-12 items-center justify-between"
v-for="(circle, i) of cockpitStore.selectedCircles" v-for="(circle, i) of cockpitStore.selectedCircles"
:key="i" :key="i"
class="flex h-12 items-center justify-between"
> >
<LearningPathDiagram <LearningPathDiagram
v-if=" v-if="

View File

@ -91,7 +91,7 @@ const countStatus = computed(() => {
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/competences`" :to="`${competenceStore.competenceProfilePage()?.frontend_url}/competences`"
class="btn-text inline-flex items-center py-2 pl-0" class="btn-text inline-flex items-center py-2 pl-0"
> >
<span>{{ $t("competences.showAll") }}</span> <span>{{ $t("general.showAll") }}</span>
<it-icon-arrow-right></it-icon-arrow-right> <it-icon-arrow-right></it-icon-arrow-right>
</router-link> </router-link>
</div> </div>
@ -137,7 +137,7 @@ const countStatus = computed(() => {
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/criteria`" :to="`${competenceStore.competenceProfilePage()?.frontend_url}/criteria`"
class="btn-text inline-flex items-center py-2 pl-0" class="btn-text inline-flex items-center py-2 pl-0"
> >
<span>{{ $t("competences.showAll") }}</span> <span>{{ $t("general.showAll") }}</span>
<it-icon-arrow-right></it-icon-arrow-right> <it-icon-arrow-right></it-icon-arrow-right>
</router-link> </router-link>
</div> </div>
@ -165,7 +165,7 @@ const countStatus = computed(() => {
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/criteria`" :to="`${competenceStore.competenceProfilePage()?.frontend_url}/criteria`"
class="btn-text inline-flex items-center py-2 pl-0" class="btn-text inline-flex items-center py-2 pl-0"
> >
<span>{{ $t("competences.showAll") }}</span> <span>{{ $t("general.showAll") }}</span>
<it-icon-arrow-right></it-icon-arrow-right> <it-icon-arrow-right></it-icon-arrow-right>
</router-link> </router-link>
</div> </div>

View File

@ -138,6 +138,14 @@ const router = createRouter({
path: "/profile", path: "/profile",
component: () => import("@/pages/ProfilePage.vue"), component: () => import("@/pages/ProfilePage.vue"),
}, },
{
path: "/settings",
component: () => import("@/pages/SettingsPage.vue"),
},
{
path: "/notifications",
component: () => import("@/pages/NotificationsPage.vue"),
},
{ {
path: "/styleguide", path: "/styleguide",
component: () => import("../pages/StyleGuidePage.vue"), component: () => import("../pages/StyleGuidePage.vue"),

View File

@ -0,0 +1,36 @@
import { itGet } from "@/fetchHelpers";
import type { Notification } from "@/types";
import { defineStore } from "pinia";
import { ref } from "vue";
type NotificationListData = {
all_count: number;
all_list: Notification[];
};
export const useNotificationsStore = defineStore("notifications", () => {
const hasUnread = ref(false);
const allCount = ref(0);
async function loadNotifications(
num_notifications: number,
mark_as_read = true
): Promise<Notification[]> {
const data = (await itGet(
`/notifications/api/all_list/?max=${num_notifications}&mark_as_read=${mark_as_read}`
)) as NotificationListData;
allCount.value = data.all_count;
await updateUnreadCount();
return data.all_list;
}
async function updateUnreadCount() {
const data = await itGet("/notifications/api/unread_count/");
hasUnread.value = data.unread_count !== 0;
}
updateUnreadCount();
setInterval(async () => await updateUnreadCount(), 30000);
return { loadNotifications, hasUnread, allCount };
});

View File

@ -1,12 +1,15 @@
import log from "loglevel"; import log from "loglevel";
import { bustItGetCache, itGetCached, itPost } from "@/fetchHelpers"; import { bustItGetCache, itGetCached, itPost } from "@/fetchHelpers";
import { loadLocaleMessages, setI18nLanguage } from "@/i18n";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
const logoutRedirectUrl = import.meta.env.VITE_LOGOUT_REDIRECT; const logoutRedirectUrl = import.meta.env.VITE_LOGOUT_REDIRECT;
// 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 availableLanguages = "de" | "fr" | "it";
export type UserState = { export type UserState = {
id: number; id: number;
first_name: string; first_name: string;
@ -17,6 +20,7 @@ export type UserState = {
is_superuser: boolean; is_superuser: boolean;
course_session_experts: number[]; course_session_experts: number[];
loggedIn: boolean; loggedIn: boolean;
language: availableLanguages;
}; };
const initialUserState: UserState = { const initialUserState: UserState = {
@ -29,8 +33,14 @@ const initialUserState: UserState = {
is_superuser: false, is_superuser: false,
course_session_experts: [], course_session_experts: [],
loggedIn: false, loggedIn: false,
language: "de",
}; };
async function setLocale(language: availableLanguages) {
await loadLocaleMessages(language);
setI18nLanguage(language);
}
export const useUserStore = defineStore({ export const useUserStore = defineStore({
id: "user", id: "user",
state: () => initialUserState as UserState, state: () => initialUserState as UserState,
@ -80,6 +90,12 @@ export const useUserStore = defineStore({
this.$state = data; this.$state = data;
this.loggedIn = true; this.loggedIn = true;
appStore.userLoaded = true; appStore.userLoaded = true;
await setLocale(data.language);
},
async setUserLanguages(language: availableLanguages) {
await setLocale(language);
this.$state.language = language;
await itPost("/api/core/me/", { language }, { method: "PUT" });
}, },
}, },
}); });

View File

@ -381,3 +381,23 @@ export interface DocumentUploadData {
name: string; name: string;
}; };
} }
// notifications
export type NotificationType = "USER_INTERACTION" | "PROGRESS" | "INFORMATION";
export interface Notification {
// given by AbstractNotification model
id: number;
timestamp: string;
unread: boolean;
actor: string | null;
verb: string;
target: string | null;
action_object: string | null;
// given by Notification model
notification_type: NotificationType;
target_url: string | null;
actor_avatar_url: string | null;
course: string | null;
}

View File

@ -0,0 +1,112 @@
import { login } from "./helpers";
describe("notifications page", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
cy.manageCommand("create_default_notifications");
});
it("can paginate notifications", () => {
login("admin", "test");
cy.visit("/notifications");
cy.get('[data-cy="no-notifications"]').should("not.exist");
cy.get("[data-cy^=notification-idx-]").should("have.length", 7);
// All notifications shall be marker as unread
cy.get("[data-cy^=notification-idx-]").within(() => {
cy.get('[data-cy="unread"]').should("exist");
});
// We load additional 7 notifications
cy.get('[data-cy="load-more-notifications"]').click();
cy.get("[data-cy^=notification-idx-]").should("have.length", 13);
cy.get('[data-cy="load-more-notifications"]').should("not.exist");
for (let i = 0; i < 7; i++) {
cy.get(`[data-cy=notification-idx-${i}]`).within(() => {
cy.get('[data-cy="unread"]').should("not.exist");
});
}
for (let i = 7; i < 13; i++) {
cy.get(`[data-cy=notification-idx-${i}]`).within(() => {
cy.get('[data-cy="unread"]').should("exist");
});
}
});
it("can click notifications", () => {
login("admin", "test");
cy.visit("/notifications");
cy.get('[data-cy="no-notifications"]').should("not.exist");
cy.get("[data-cy=notification-target-idx-0]").click();
cy.location().should((loc) => {
expect(loc.pathname).to.eq("/");
});
});
});
describe("notification popover", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
cy.manageCommand("create_default_notifications");
});
function toggleNotificationPopover() {
cy.get('[data-cy="notification-bell-button"]')
.filter(":visible")
.click({ force: true });
}
it("displays four notifications", () => {
login("admin", "test");
cy.visit("/");
cy.wait(1000);
toggleNotificationPopover();
cy.get("[data-cy^=notification-idx-]").should("have.length", 4);
});
it("can show all notifications", () => {
login("admin", "test");
cy.visit("/");
cy.wait(1000);
toggleNotificationPopover();
cy.get('[data-cy="show-all-notifications"]').click({ force: true });
cy.location().should((loc) => {
expect(loc.pathname).to.eq("/notifications");
});
});
});
describe("email notification settings", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
cy.manageCommand("create_default_notifications");
});
it("can update email notification settings", () => {
login("admin", "test");
cy.visit("/settings");
cy.wait(1000);
cy.get('[data-cy="it-checkbox-USER_INTERACTION"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-INFORMATION"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-PROGRESS"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-USER_INTERACTION"]').click({ force: true });
cy.reload();
cy.wait(1000);
cy.get('[data-cy="it-checkbox-USER_INTERACTION"]').should("be.checked");
cy.get('[data-cy="it-checkbox-INFORMATION"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-PROGRESS"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-USER_INTERACTION"]').click({ force: true });
cy.reload();
cy.wait(1000);
cy.get('[data-cy="it-checkbox-USER_INTERACTION"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-INFORMATION"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-PROGRESS"]').should("not.be.checked");
});
});

BIN
env_secrets/local_elia.env Normal file

Binary file not shown.

View File

@ -1,7 +1,8 @@
encrypted: env_secrets/caprover_dev.env encrypted: env_secrets/caprover_dev.env
encrypted: env_secrets/caprover_prod.env encrypted: env_secrets/caprover_prod.env
encrypted: env_secrets/caprover_stage.env encrypted: env_secrets/caprover_stage.env
encrypted: env_secrets/local_chrigu.env encrypted: env_secrets/local_chrigu.env
encrypted: env_secrets/local_daniel.env encrypted: env_secrets/local_daniel.env
encrypted: env_secrets/local_lorenz.env encrypted: env_secrets/local_elia.env
encrypted: env_secrets/production.env encrypted: env_secrets/local_lorenz.env
encrypted: env_secrets/production.env

View File

@ -69,6 +69,7 @@ if [ "$SKIP_SETUP" = false ]; then
python server/manage.py migrate python server/manage.py migrate
python server/manage.py create_default_users python server/manage.py create_default_users
python server/manage.py create_default_courses python server/manage.py create_default_courses
python server/manage.py create_default_notifications
# make django translations # make django translations
(cd server && python manage.py compilemessages) (cd server && python manage.py compilemessages)

View File

@ -2,7 +2,6 @@
Base settings to build other settings files upon. Base settings to build other settings files upon.
""" """
import logging import logging
import os
from pathlib import Path from pathlib import Path
import structlog import structlog
@ -101,6 +100,7 @@ THIRD_PARTY_APPS = [
"storages", "storages",
"grapple", "grapple",
"graphene_django", "graphene_django",
"notifications",
] ]
LOCAL_APPS = [ LOCAL_APPS = [
@ -112,6 +112,7 @@ LOCAL_APPS = [
"vbv_lernwelt.media_library", "vbv_lernwelt.media_library",
"vbv_lernwelt.feedback", "vbv_lernwelt.feedback",
"vbv_lernwelt.files", "vbv_lernwelt.files",
"vbv_lernwelt.notify",
] ]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
@ -221,6 +222,7 @@ IT_SERVE_VUE_URL = env("IT_SERVE_VUE_URL", "http://localhost:5173")
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
WAGTAIL_SITE_NAME = "VBV Lernwelt" WAGTAIL_SITE_NAME = "VBV Lernwelt"
WAGTAIL_I18N_ENABLED = True WAGTAIL_I18N_ENABLED = True
WAGTAILADMIN_BASE_URL = "/server/cms/"
LANGUAGES = [ LANGUAGES = [
("en-US", "English (American)"), ("en-US", "English (American)"),
@ -573,6 +575,13 @@ GRAPPLE = {
"APPS": ["core", "course", "learnpath", "competence", "media_library"], "APPS": ["core", "course", "learnpath", "competence", "media_library"],
} }
# Notifications
# django-notifications
DJANGO_NOTIFICATIONS_CONFIG = {"SOFT_DELETE": True}
NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification"
# sendgrid (email notifications)
SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="")
# S3 BUCKET CONFIGURATION # S3 BUCKET CONFIGURATION
FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="local") # local | s3 FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="local") # local | s3
@ -716,6 +725,3 @@ if APP_ENVIRONMENT in ["production", "caprover"] or APP_ENVIRONMENT.startswith(
environment=env("SENTRY_ENVIRONMENT", default="production"), environment=env("SENTRY_ENVIRONMENT", default="production"),
traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0), traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0),
) )
# Your stuff...
# ------------------------------------------------------------------------------

View File

@ -1,3 +1,4 @@
import notifications.urls
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
@ -36,6 +37,7 @@ from vbv_lernwelt.feedback.views import (
get_expert_feedbacks_for_course, get_expert_feedbacks_for_course,
get_feedback_for_circle, get_feedback_for_circle,
) )
from vbv_lernwelt.notify.views import email_notification_settings
from wagtail import urls as wagtail_urls from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls from wagtail.documents import urls as wagtaildocs_urls
@ -66,6 +68,13 @@ urlpatterns = [
name='vue_login'), name='vue_login'),
re_path(r'api/core/logout/$', vue_logout, name='vue_logout'), re_path(r'api/core/logout/$', vue_logout, name='vue_logout'),
# notifications
re_path(r'^notifications/', include(notifications.urls, namespace='notifications')),
# notify
re_path(r"api/notify/email_notification_settings/$", email_notification_settings,
name='email_notification_settings'),
# core # core
re_path(r"server/core/icons/$", generate_web_component_icons, re_path(r"server/core/icons/$", generate_web_component_icons,
name="generate_web_component_icons"), name="generate_web_component_icons"),

View File

@ -10,6 +10,8 @@ anyascii==0.3.1
# via wagtail # via wagtail
anyio==3.5.0 anyio==3.5.0
# via watchfiles # via watchfiles
appnope==0.1.3
# via ipython
argon2-cffi==21.3.0 argon2-cffi==21.3.0
# via -r requirements.in # via -r requirements.in
argon2-cffi-bindings==21.2.0 argon2-cffi-bindings==21.2.0
@ -103,6 +105,7 @@ django==3.2.13
# django-filter # django-filter
# django-model-utils # django-model-utils
# django-modelcluster # django-modelcluster
# django-notifications-hq
# django-permissionedforms # django-permissionedforms
# django-redis # django-redis
# django-storages # django-storages
@ -114,6 +117,7 @@ django==3.2.13
# djangorestframework # djangorestframework
# drf-spectacular # drf-spectacular
# graphene-django # graphene-django
# jsonfield
# wagtail # wagtail
# wagtail-grapple # wagtail-grapple
# wagtail-localize # wagtail-localize
@ -134,9 +138,13 @@ django-filter==21.1
django-ipware==4.0.2 django-ipware==4.0.2
# via -r requirements.in # via -r requirements.in
django-model-utils==4.2.0 django-model-utils==4.2.0
# via -r requirements.in # via
# -r requirements.in
# django-notifications-hq
django-modelcluster==6.0 django-modelcluster==6.0
# via wagtail # via wagtail
django-notifications-hq==1.7.0
# via -r requirements.in
django-permissionedforms==0.1 django-permissionedforms==0.1
# via wagtail # via wagtail
django-ratelimit==3.0.1 django-ratelimit==3.0.1
@ -242,6 +250,8 @@ jmespath==1.0.1
# via # via
# boto3 # boto3
# botocore # botocore
jsonfield==3.1.0
# via django-notifications-hq
jsonschema==4.4.0 jsonschema==4.4.0
# via drf-spectacular # via drf-spectacular
l18n==2021.3 l18n==2021.3
@ -370,6 +380,8 @@ python-dotenv==0.20.0
# via # via
# environs # environs
# uvicorn # uvicorn
python-http-client==3.3.7
# via sendgrid
python-json-logger==2.0.2 python-json-logger==2.0.2
# via -r requirements.in # via -r requirements.in
python-slugify==6.1.1 python-slugify==6.1.1
@ -379,6 +391,7 @@ pytz==2022.1
# -r requirements.in # -r requirements.in
# django # django
# django-modelcluster # django-modelcluster
# django-notifications-hq
# djangorestframework # djangorestframework
# l18n # l18n
pyyaml==6.0 pyyaml==6.0
@ -400,6 +413,8 @@ rx==1.6.1
# via graphql-core # via graphql-core
s3transfer==0.6.0 s3transfer==0.6.0
# via boto3 # via boto3
sendgrid==6.9.7
# via -r requirements.in
sentry-sdk==1.5.8 sentry-sdk==1.5.8
# via -r requirements.in # via -r requirements.in
singledispatch==3.7.0 singledispatch==3.7.0
@ -430,10 +445,14 @@ sqlparse==0.4.2
# django-debug-toolbar # django-debug-toolbar
stack-data==0.2.0 stack-data==0.2.0
# via ipython # via ipython
starkbank-ecdsa==2.2.0
# via sendgrid
stdlibs==2022.6.8 stdlibs==2022.6.8
# via usort # via usort
structlog==21.5.0 structlog==21.5.0
# via -r requirements.in # via -r requirements.in
swapper==1.3.0
# via django-notifications-hq
tablib[xls,xlsx]==3.2.1 tablib[xls,xlsx]==3.2.1
# via wagtail # via wagtail
telepath==0.2 telepath==0.2

View File

@ -24,10 +24,12 @@ django-ratelimit
django-ipware django-ipware
django-csp django-csp
django-storages django-storages
django-notifications-hq
psycopg2-binary psycopg2-binary
gunicorn gunicorn
sentry-sdk sentry-sdk
sendgrid
structlog structlog
python-json-logger python-json-logger

View File

@ -63,6 +63,7 @@ django==3.2.13
# django-filter # django-filter
# django-model-utils # django-model-utils
# django-modelcluster # django-modelcluster
# django-notifications-hq
# django-permissionedforms # django-permissionedforms
# django-redis # django-redis
# django-storages # django-storages
@ -71,6 +72,7 @@ django==3.2.13
# djangorestframework # djangorestframework
# drf-spectacular # drf-spectacular
# graphene-django # graphene-django
# jsonfield
# wagtail # wagtail
# wagtail-grapple # wagtail-grapple
# wagtail-localize # wagtail-localize
@ -85,9 +87,13 @@ django-filter==21.1
django-ipware==4.0.2 django-ipware==4.0.2
# via -r requirements.in # via -r requirements.in
django-model-utils==4.2.0 django-model-utils==4.2.0
# via -r requirements.in # via
# -r requirements.in
# django-notifications-hq
django-modelcluster==6.0 django-modelcluster==6.0
# via wagtail # via wagtail
django-notifications-hq==1.7.0
# via -r requirements.in
django-permissionedforms==0.1 django-permissionedforms==0.1
# via wagtail # via wagtail
django-ratelimit==3.0.1 django-ratelimit==3.0.1
@ -147,6 +153,8 @@ jmespath==1.0.1
# via # via
# boto3 # boto3
# botocore # botocore
jsonfield==3.1.0
# via django-notifications-hq
jsonschema==4.4.0 jsonschema==4.4.0
# via drf-spectacular # via drf-spectacular
l18n==2021.3 l18n==2021.3
@ -188,6 +196,8 @@ python-dotenv==0.20.0
# via # via
# environs # environs
# uvicorn # uvicorn
python-http-client==3.3.7
# via sendgrid
python-json-logger==2.0.2 python-json-logger==2.0.2
# via -r requirements.in # via -r requirements.in
python-slugify==6.1.1 python-slugify==6.1.1
@ -197,6 +207,7 @@ pytz==2022.1
# -r requirements.in # -r requirements.in
# django # django
# django-modelcluster # django-modelcluster
# django-notifications-hq
# djangorestframework # djangorestframework
# l18n # l18n
pyyaml==6.0 pyyaml==6.0
@ -213,6 +224,8 @@ rx==1.6.1
# via graphql-core # via graphql-core
s3transfer==0.6.0 s3transfer==0.6.0
# via boto3 # via boto3
sendgrid==6.9.7
# via -r requirements.in
sentry-sdk==1.5.8 sentry-sdk==1.5.8
# via -r requirements.in # via -r requirements.in
singledispatch==3.7.0 singledispatch==3.7.0
@ -234,8 +247,12 @@ soupsieve==2.3.2.post1
# via beautifulsoup4 # via beautifulsoup4
sqlparse==0.4.2 sqlparse==0.4.2
# via django # via django
starkbank-ecdsa==2.2.0
# via sendgrid
structlog==21.5.0 structlog==21.5.0
# via -r requirements.in # via -r requirements.in
swapper==1.3.0
# via django-notifications-hq
tablib[xls,xlsx]==3.2.1 tablib[xls,xlsx]==3.2.1
# via wagtail # via wagtail
telepath==0.2 telepath==0.2

View File

@ -79,6 +79,10 @@ type Circle implements PageInterface {
ancestors(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface!]! ancestors(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface!]!
} }
type CircleDocument {
id: ID
}
type CollectionObjectType { type CollectionObjectType {
id: ID! id: ID!
path: String! path: String!
@ -281,6 +285,13 @@ type ErrorType {
messages: [String!]! messages: [String!]!
} }
type FeedbackResponse implements Node {
id: ID!
data: GenericScalar
circle: Circle!
courseSession: CourseSession!
}
type FloatBlock implements StreamFieldInterface { type FloatBlock implements StreamFieldInterface {
id: String id: String
blockType: String! blockType: String!
@ -289,6 +300,8 @@ type FloatBlock implements StreamFieldInterface {
value: Float! value: Float!
} }
scalar GenericScalar
type ImageChooserBlock implements StreamFieldInterface { type ImageChooserBlock implements StreamFieldInterface {
id: String id: String
blockType: String! blockType: String!
@ -608,6 +621,10 @@ type Mutation {
sendFeedback(input: SendFeedbackInput!): SendFeedbackPayload sendFeedback(input: SendFeedbackInput!): SendFeedbackPayload
} }
interface Node {
id: ID!
}
type Page implements PageInterface { type Page implements PageInterface {
id: ID id: ID
path: String! path: String!
@ -783,42 +800,21 @@ type RichTextBlock implements StreamFieldInterface {
value: String! value: String!
} }
union Search = CoursePage | LearningPath | Topic | Circle | LearningSequence | LearningUnit | LearningContent | CompetenceProfilePage | CompetencePage | PerformanceCriteria | MediaLibraryPage | MediaCategoryPage | Page | LibraryDocument | User | SecurityRequestResponseLog | Course | CourseCategory | CourseCompletion | CourseSession | CourseSessionUser union Search = CoursePage | LearningPath | Topic | Circle | LearningSequence | LearningUnit | LearningContent | CompetenceProfilePage | CompetencePage | PerformanceCriteria | MediaLibraryPage | MediaCategoryPage | Page | LibraryDocument | User | SecurityRequestResponseLog | Course | CourseCategory | CourseCompletion | CourseSession | CourseSessionUser | CircleDocument
type SecurityRequestResponseLog { type SecurityRequestResponseLog {
id: ID id: ID
} }
input SendFeedbackInput { input SendFeedbackInput {
id: Int
page: String! page: String!
satisfaction: Int courseSession: Int!
goalAttainment: Int data: GenericScalar
proficiency: Int
receivedMaterials: Boolean
materialsRating: Int
instructorCompetence: Int
instructorRespect: Int
instructorOpenFeedback: String
wouldRecommend: Boolean
coursePositiveFeedback: String
courseNegativeFeedback: String
clientMutationId: String clientMutationId: String
} }
type SendFeedbackPayload { type SendFeedbackPayload {
id: Int feedbackResponse: FeedbackResponse
satisfaction: Int
goalAttainment: Int
proficiency: Int
receivedMaterials: Boolean
materialsRating: Int
instructorCompetence: Int
instructorRespect: Int
instructorOpenFeedback: String
wouldRecommend: Boolean
coursePositiveFeedback: String
courseNegativeFeedback: String
errors: [ErrorType] errors: [ErrorType]
clientMutationId: String clientMutationId: String
} }

View File

@ -7,7 +7,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View File

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [] dependencies = []
operations = [ operations = [

View File

@ -3,7 +3,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("sites", "0001_initial")] dependencies = [("sites", "0001_initial")]
operations = [ operations = [

View File

@ -57,7 +57,6 @@ def update_site_backward(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("sites", "0002_alter_domain_unique")] dependencies = [("sites", "0002_alter_domain_unique")]
operations = [migrations.RunPython(update_site_forward, update_site_backward)] operations = [migrations.RunPython(update_site_forward, update_site_backward)]

View File

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("sites", "0003_set_site_domain_and_name"), ("sites", "0003_set_site_domain_and_name"),
] ]

View File

@ -1,9 +1,11 @@
import djclick as click import djclick as click
from vbv_lernwelt.course.models import CourseCompletion from vbv_lernwelt.course.models import CourseCompletion
from vbv_lernwelt.notify.models import Notification
@click.command() @click.command()
def command(): def command():
print("cypress reset data") print("cypress reset data")
CourseCompletion.objects.all().delete() CourseCompletion.objects.all().delete()
Notification.objects.all().delete()

View File

@ -7,7 +7,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("core", "0001_initial"), ("core", "0001_initial"),
] ]
@ -22,4 +21,13 @@ class Migration(migrations.Migration):
max_length=254, unique=True, verbose_name="email address" max_length=254, unique=True, verbose_name="email address"
), ),
), ),
migrations.AddField(
model_name="user",
name="language",
field=models.CharField(
choices=[("de", "Deutsch"), ("fr", "Français"), ("it", "Italiano")],
default="de",
max_length=2,
),
),
] ]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("core", "0003_alter_user_managers"), ("core", "0003_alter_user_managers"),
] ]

View File

@ -1,15 +1,15 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group
from django.db import migrations from django.db import migrations
from vbv_lernwelt.core.create_default_users import create_default_users from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
def create_users(apps, schema_editor): def create_users(apps, schema_editor):
default_password = "ACEEs0DCmNaPxdoNV8vhccuCTRl9b" default_password = "ACEEs0DCmNaPxdoNV8vhccuCTRl9b"
if settings.APP_ENVIRONMENT == "development": if settings.APP_ENVIRONMENT == "development":
default_password = None default_password = None
User = apps.get_model("core", "User")
Group = apps.get_model("auth", "Group")
create_default_users( create_default_users(
user_model=User, group_model=Group, default_password=default_password user_model=User, group_model=Group, default_password=default_password
) )

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.13 on 2023-01-24 09:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0005_create_users"),
]
operations = [
migrations.AddField(
model_name="user",
name="additional_json_data",
field=models.JSONField(blank=True, default=dict),
),
]

View File

@ -11,6 +11,12 @@ class User(AbstractUser):
If adding fields that need to be filled at user signup, If adding fields that need to be filled at user signup,
""" """
LANGUAGE_CHOICES = (
("de", "Deutsch"),
("fr", "Français"),
("it", "Italiano"),
)
# FIXME: look into it... # FIXME: look into it...
# objects = UserManager() # objects = UserManager()
avatar_url = models.CharField(max_length=254, blank=True, default="") avatar_url = models.CharField(max_length=254, blank=True, default="")
@ -18,6 +24,8 @@ class User(AbstractUser):
sso_id = models.UUIDField( sso_id = models.UUIDField(
"SSO subscriber ID", unique=True, null=True, blank=True, default=None "SSO subscriber ID", unique=True, null=True, blank=True, default=None
) )
additional_json_data = JSONField(default=dict, blank=True)
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="de")
objects = UserManager() objects = UserManager()

View File

@ -18,6 +18,15 @@ class UserSerializer(serializers.ModelSerializer):
"avatar_url", "avatar_url",
"is_superuser", "is_superuser",
"course_session_experts", "course_session_experts",
"language",
]
read_only_fields = [
"id",
"is_superuser",
"first_name",
"last_name",
"email",
"username",
] ]
def get_course_session_experts(self, obj): def get_course_session_experts(self, obj):

View File

@ -82,11 +82,25 @@ def vue_login(request):
) )
@api_view(["GET"]) @api_view(["GET", "PUT"])
def me_user_view(request): def me_user_view(request):
if request.user.is_authenticated: if not request.user.is_authenticated:
return Response(status=403)
if request.method == "GET":
return Response(UserSerializer(request.user).data) return Response(UserSerializer(request.user).data)
return Response(status=403)
if request.method == "PUT":
serializer = UserSerializer(
request.user,
data={"language": request.data.get("language", "de")},
partial=True,
)
if serializer.is_valid():
serializer.save()
return Response(UserSerializer(request.user).data)
return Response(status=400)
@api_view(["POST"]) @api_view(["POST"])

View File

@ -246,7 +246,7 @@ def command():
) )
# initial completion data # initial completion data
for (slug, status, email) in [ for slug, status, email in [
( (
"überbetriebliche-kurse-competence-crit-a21-allgemein", "überbetriebliche-kurse-competence-crit-a21-allgemein",
"success", "success",

View File

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View File

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("course", "0001_initial"), ("course", "0001_initial"),

View File

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("course", "0002_auto_20221014_0933"), ("course", "0002_auto_20221014_0933"),
] ]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0008_alter_learningcontent_contents"), ("learnpath", "0008_alter_learningcontent_contents"),
("course", "0003_alter_coursepage_course"), ("course", "0003_alter_coursepage_course"),

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0008_alter_learningcontent_contents"), ("learnpath", "0008_alter_learningcontent_contents"),
("course", "0004_coursesessionuser_expert"), ("course", "0004_coursesessionuser_expert"),

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("course", "0005_alter_coursesessionuser_expert"), ("course", "0005_alter_coursesessionuser_expert"),
] ]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("course", "0006_course_slug"), ("course", "0006_course_slug"),
] ]

View File

@ -1,3 +1,4 @@
from factory import Dict
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
from factory.fuzzy import FuzzyChoice, FuzzyInteger from factory.fuzzy import FuzzyChoice, FuzzyInteger
@ -5,33 +6,37 @@ from vbv_lernwelt.feedback.models import FeedbackResponse
class FeedbackFactory(DjangoModelFactory): class FeedbackFactory(DjangoModelFactory):
data = Dict(
{
"satisfaction": FuzzyInteger(2, 4),
"goal_attainment": FuzzyInteger(3, 4),
"proficiency": FuzzyChoice([20, 40, 60, 80]),
"received_materials": FuzzyChoice([True, False]),
"materials_rating": FuzzyInteger(2, 4),
"instructor_competence": FuzzyInteger(3, 4),
"instructor_respect": FuzzyInteger(3, 4),
"instructor_open_feedback": FuzzyChoice(
[
"Alles gut, manchmal etwas langfädig",
"Super, bin begeistert",
"Ok, enspricht den Erwartungen",
]
),
"would_recommend": FuzzyChoice([True, False]),
"course_positive_feedback": FuzzyChoice(
[
"Die Präsentation war super",
"Das Beispiel mit der Katze fand ich sehr gut veranschaulicht!",
]
),
"course_negative_feedback": FuzzyChoice(
[
"Es wäre praktisch, Zugang zu einer FAQ zu haben.",
"Es wäre schön, mehr Videos hinzuzufügen.",
]
),
}
)
class Meta: class Meta:
model = FeedbackResponse model = FeedbackResponse
satisfaction = FuzzyInteger(2, 4)
goal_attainment = FuzzyInteger(3, 4)
proficiency = FuzzyChoice([20, 40, 60, 80])
received_materials = FuzzyChoice([True, False])
materials_rating = FuzzyInteger(2, 4)
instructor_competence = FuzzyInteger(3, 4)
instructor_respect = FuzzyInteger(3, 4)
instructor_open_feedback = FuzzyChoice(
[
"Alles gut, manchmal etwas langfädig",
"Super, bin begeistert",
"Ok, enspricht den Erwartungen",
]
)
would_recommend = FuzzyChoice([True, False])
course_positive_feedback = FuzzyChoice(
[
"Die Präsentation war super",
"Das Beispiel mit der Katze fand ich sehr gut veranschaulicht!",
]
)
course_negative_feedback = FuzzyChoice(
[
"Es wäre praktisch, Zugang zu einer FAQ zu haben.",
"Es wäre schön, mehr Videos hinzuzufügen.",
]
)

View File

@ -1,13 +1,56 @@
import graphene import structlog
from graphene_django.rest_framework.mutation import SerializerMutation from graphene import ClientIDMutation, Field, Int, List, String
from graphene.types.generic import GenericScalar
from graphene_django.types import ErrorType
from vbv_lernwelt.feedback.serializers import FeedbackResponseSerializer from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.feedback.graphql.types import FeedbackResponse as FeedbackResponseType
from vbv_lernwelt.feedback.models import FeedbackResponse
from vbv_lernwelt.feedback.serializers import CourseFeedbackSerializer
from wagtail.models import Page
logger = structlog.get_logger(__name__)
class SendFeedback(SerializerMutation): # https://medium.com/open-graphql/jsonfield-models-in-graphene-django-308ae43d14ee
class Meta: class SendFeedback(ClientIDMutation):
serializer_class = FeedbackResponseSerializer feedback_response = Field(FeedbackResponseType)
model_operations = ["create"] errors = List(
ErrorType, description="May contain more than one error for same field."
)
class Input:
page = String(required=True)
course_session = Int(required=True)
data = GenericScalar()
@classmethod
def mutate_and_get_payload(cls, _, info, **input):
page_key = input["page"]
course_session_id = input["course_session"]
logger.info("creating feedback")
learning_content = Page.objects.get(
translation_key=page_key, locale__language_code="de-CH"
)
circle = learning_content.get_parent().specific
course_session = CourseSession.objects.get(id=course_session_id)
data = input.get("data", {})
serializer = CourseFeedbackSerializer(data=data)
if not serializer.is_valid():
logger.error(serializer.errors)
return SendFeedback(errors=serializer.errors)
feedback_response = FeedbackResponse.objects.create(
circle=circle,
course_session=course_session,
data=serializer.validated_data,
)
logger.info(feedback_response)
return SendFeedback(feedback_response=feedback_response)
class Mutation(object): class Mutation(object):

View File

@ -1,8 +1,13 @@
from graphene.relay import Node
from graphene.types.generic import GenericScalar
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from vbv_lernwelt.feedback.models import Feedback from vbv_lernwelt.feedback.models import FeedbackResponse as FeedbackResponseModel
# class FeedbackType(DjangoObjectType): class FeedbackResponse(DjangoObjectType):
# class Meta: data = GenericScalar()
# model = Feedback
class Meta:
model = FeedbackResponseModel
interfaces = (Node,)

View File

@ -8,7 +8,6 @@ import vbv_lernwelt.feedback.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View File

@ -7,7 +7,6 @@ import vbv_lernwelt.feedback.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("feedback", "0001_initial"), ("feedback", "0001_initial"),
] ]

View File

@ -0,0 +1,66 @@
# Generated by Django 3.2.13 on 2023-02-06 10:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("feedback", "0002_auto_20230111_1044"),
]
operations = [
migrations.RemoveField(
model_name="feedbackresponse",
name="course_negative_feedback",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="course_positive_feedback",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="goal_attainment",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="instructor_competence",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="instructor_open_feedback",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="instructor_respect",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="materials_rating",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="proficiency",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="received_materials",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="satisfaction",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="would_recommend",
),
migrations.AddField(
model_name="feedbackresponse",
name="data",
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name="feedbackresponse",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@ -1,8 +1,11 @@
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.notify.service import NotificationService
class FeedbackIntegerField(models.IntegerField): class FeedbackIntegerField(models.IntegerField):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@ -11,7 +14,7 @@ class FeedbackIntegerField(models.IntegerField):
super().__init__( super().__init__(
validators=validators + [MinValueValidator(1), MaxValueValidator(4)], validators=validators + [MinValueValidator(1), MaxValueValidator(4)],
null=nullable, null=nullable,
**kwargs **kwargs,
) )
@ -37,17 +40,33 @@ class FeedbackResponse(models.Model):
EIGHTY = 80, "80%" EIGHTY = 80, "80%"
HUNDRED = 100, "100%" HUNDRED = 100, "100%"
satisfaction = FeedbackIntegerField() def save(self, *args, **kwargs):
goal_attainment = FeedbackIntegerField() if not self.id:
proficiency = models.IntegerField(null=True) course_session_users = CourseSessionUser.objects.filter(
received_materials = models.BooleanField(null=True) role="EXPERT", course_session=self.course_session, expert=self.circle
materials_rating = FeedbackIntegerField() )
instructor_competence = FeedbackIntegerField() for csu in course_session_users:
instructor_respect = FeedbackIntegerField() NotificationService.send_information_notification(
instructor_open_feedback = models.TextField(blank=True) recipient=csu.user,
would_recommend = models.BooleanField(null=True) verb=f"{_('New feedback for circle')} {self.circle.title}",
course_positive_feedback = models.TextField(blank=True) target_url=f"/course/{self.course_session.course.slug}/cockpit/feedback/{self.circle_id}/",
course_negative_feedback = models.TextField(blank=True) )
super(FeedbackResponse, self).save(*args, **kwargs)
# satisfaction = FeedbackIntegerField()
# goal_attainment = FeedbackIntegerField()
# proficiency = models.IntegerField(null=True)
# received_materials = models.BooleanField(null=True)
# materials_rating = FeedbackIntegerField()
# instructor_competence = FeedbackIntegerField()
# instructor_respect = FeedbackIntegerField()
# instructor_open_feedback = models.TextField(blank=True)
# would_recommend = models.BooleanField(null=True)
# course_positive_feedback = models.TextField(blank=True)
# course_negative_feedback = models.TextField(blank=True)
data = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
circle = models.ForeignKey("learnpath.Circle", models.PROTECT) circle = models.ForeignKey("learnpath.Circle", models.PROTECT)
course_session = models.ForeignKey("course.CourseSession", models.PROTECT) course_session = models.ForeignKey("course.CourseSession", models.PROTECT)

View File

@ -1,32 +1,31 @@
import structlog import structlog
from rest_framework import serializers from rest_framework import serializers
from wagtail.models import Page
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.feedback.models import FeedbackResponse
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
class FeedbackResponseSerializer(serializers.ModelSerializer): class FeedbackIntegerField(serializers.IntegerField):
page = serializers.CharField(write_only=True) def __init__(self, **kwargs):
course_session = serializers.CharField(write_only=True) super().__init__(
required=False, allow_null=True, min_value=1, max_value=5, **kwargs
class Meta:
model = FeedbackResponse
exclude = ["circle"]
# extra_kwargs = {"course", {"read_only": True}}
def create(self, validated_data):
logger.info("creating feedback")
page_key = validated_data.pop("page")
course_session_id = validated_data.pop("course_session")
learning_content = Page.objects.get(
translation_key=page_key, locale__language_code="de-CH"
)
circle = learning_content.get_parent().specific
course_session = CourseSession.objects.get(id=course_session_id)
return FeedbackResponse.objects.create(
**validated_data, circle=circle, course_session=course_session
) )
class CourseFeedbackSerializer(serializers.Serializer):
satisfaction = FeedbackIntegerField()
goal_attainment = FeedbackIntegerField()
proficiency = serializers.IntegerField(required=False, allow_null=True)
received_materials = serializers.BooleanField(required=False, allow_null=True)
materials_rating = FeedbackIntegerField()
instructor_competence = FeedbackIntegerField()
instructor_respect = FeedbackIntegerField()
instructor_open_feedback = serializers.CharField(
required=False, allow_null=True, allow_blank=True
)
would_recommend = serializers.BooleanField(required=False, allow_null=True)
course_positive_feedback = serializers.CharField(
required=False, allow_null=True, allow_blank=True
)
course_negative_feedback = serializers.CharField(
required=False, allow_null=True, allow_blank=True
)

View File

@ -7,6 +7,7 @@ from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.feedback.factories import FeedbackFactory from vbv_lernwelt.feedback.factories import FeedbackFactory
from vbv_lernwelt.learnpath.models import Circle from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.notify.models import Notification
class FeedbackApiBaseTestCase(APITestCase): class FeedbackApiBaseTestCase(APITestCase):
@ -50,6 +51,54 @@ class FeedbackApiBaseTestCase(APITestCase):
class FeedbackSummaryApiTestCase(FeedbackApiBaseTestCase): class FeedbackSummaryApiTestCase(FeedbackApiBaseTestCase):
def test_triggers_notification(self):
expert = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
csu = CourseSessionUser.objects.get(
course_session=self.course_session,
user=expert,
role=CourseSessionUser.Role.EXPERT,
)
basis_circle = Circle.objects.get(slug="test-lehrgang-lp-circle-basis")
csu.expert.add(basis_circle)
FeedbackFactory(circle=basis_circle, course_session=csu.course_session).save()
notifications = Notification.objects.all()
self.assertEqual(len(notifications), 1)
self.assertEqual(notifications[0].recipient, expert)
self.assertEqual(
notifications[0].verb, f"New feedback for circle {basis_circle.title}"
)
self.assertEqual(
notifications[0].target_url,
f"/course/{self.course_session.course.slug}/cockpit/feedback/{basis_circle.id}/",
)
def test_triggers_notification_only_on_create(self):
expert = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
csu = CourseSessionUser.objects.get(
course_session=self.course_session,
user=expert,
role=CourseSessionUser.Role.EXPERT,
)
basis_circle = Circle.objects.get(slug="test-lehrgang-lp-circle-basis")
csu.expert.add(basis_circle)
feedback = FeedbackFactory(
circle=basis_circle, course_session=csu.course_session
)
feedback.save()
# Check that the initial notification was created and then deleted
self.assertEqual(len(Notification.objects.all()), 1)
Notification.objects.all().delete()
self.assertEqual(len(Notification.objects.all()), 0)
# Check that an update of the feedback does not trigger a notification
feedback.name = "Test2"
feedback.save()
self.assertEqual(len(Notification.objects.all()), 0)
def test_can_get_feedback_summary_for_circles(self): def test_can_get_feedback_summary_for_circles(self):
number_basis_feedback = 5 number_basis_feedback = 5
number_analyse_feedback = 10 number_analyse_feedback = 10
@ -159,17 +208,25 @@ class FeedbackDetailApiTestCase(FeedbackApiBaseTestCase):
FeedbackFactory( FeedbackFactory(
circle=circle, circle=circle,
course_session=csu.course_session, course_session=csu.course_session,
satisfaction=feedback_data["satisfaction"][i], data={
goal_attainment=feedback_data["goal_attainment"][i], "satisfaction": feedback_data["satisfaction"][i],
proficiency=feedback_data["proficiency"][i], "goal_attainment": feedback_data["goal_attainment"][i],
received_materials=feedback_data["received_materials"][i], "proficiency": feedback_data["proficiency"][i],
materials_rating=feedback_data["materials_rating"][i], "received_materials": feedback_data["received_materials"][i],
instructor_competence=feedback_data["instructor_competence"][i], "materials_rating": feedback_data["materials_rating"][i],
instructor_open_feedback=feedback_data["instructor_open_feedback"][i], "instructor_competence": feedback_data["instructor_competence"][i],
instructor_respect=feedback_data["instructor_respect"][i], "instructor_open_feedback": feedback_data[
would_recommend=feedback_data["would_recommend"][i], "instructor_open_feedback"
course_positive_feedback=feedback_data["course_positive_feedback"][i], ][i],
course_negative_feedback=feedback_data["course_negative_feedback"][i], "instructor_respect": feedback_data["instructor_respect"][i],
"would_recommend": feedback_data["would_recommend"][i],
"course_positive_feedback": feedback_data[
"course_positive_feedback"
][i],
"course_negative_feedback": feedback_data[
"course_negative_feedback"
][i],
},
).save() ).save()
response = self.client.get( response = self.client.get(

View File

@ -49,7 +49,7 @@ def get_feedback_for_circle(request, course_id, circle_id):
course_session__course_id=course_id, course_session__course_id=course_id,
circle__expert__user=request.user, circle__expert__user=request.user,
circle_id=circle_id, circle_id=circle_id,
) ).order_by("created_at")
# I guess this is ok for the üK case # I guess this is ok for the üK case
feedback_data = {"amount": len(feedbacks), "questions": {}} feedback_data = {"amount": len(feedbacks), "questions": {}}
@ -62,6 +62,8 @@ def get_feedback_for_circle(request, course_id, circle_id):
for feedback in feedbacks: for feedback in feedbacks:
for field in FEEDBACK_FIELDS: for field in FEEDBACK_FIELDS:
feedback_data["questions"][field].append(getattr(feedback, field)) data = feedback.data.get(field, None)
if data is not None:
feedback_data["questions"][field].append(data)
return Response(status=200, data=feedback_data) return Response(status=200, data=feedback_data)

View File

@ -8,7 +8,6 @@ import vbv_lernwelt.files.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View File

@ -8,7 +8,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0001_initial"), ("learnpath", "0001_initial"),
] ]

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0002_alter_learningcontent_contents"), ("learnpath", "0002_alter_learningcontent_contents"),
] ]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0003_alter_learningcontent_contents"), ("learnpath", "0003_alter_learningcontent_contents"),
] ]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0004_circle_goal_description"), ("learnpath", "0004_circle_goal_description"),
] ]

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0005_circle_job_situation_description"), ("learnpath", "0005_circle_job_situation_description"),
] ]

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0006_alter_learningcontent_contents"), ("learnpath", "0006_alter_learningcontent_contents"),
] ]

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0007_alter_learningcontent_contents"), ("learnpath", "0007_alter_learningcontent_contents"),
] ]

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0008_alter_learningcontent_contents"), ("learnpath", "0008_alter_learningcontent_contents"),
] ]

View File

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("learnpath", "0009_alter_learningcontent_contents"), ("learnpath", "0009_alter_learningcontent_contents"),
] ]

View File

@ -11,7 +11,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("media_library", "0001_initial"), ("media_library", "0001_initial"),
] ]

View File

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotifyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "vbv_lernwelt.notify"

View File

@ -0,0 +1,150 @@
from datetime import timedelta
from django.utils import timezone
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.notify.models import NotificationType
from vbv_lernwelt.notify.tests.factories import NotificationFactory
def create_default_notifications() -> int:
"""
Creates default notifications for all users.
@return: The number of created notifications per user.
"""
avatar_urls = [
"/static/avatars/avatar_alexandra.png",
"/static/avatars/avatar_bianca.png",
"/static/avatars/avatar_chantal.png",
]
timestamps = [timezone.now() - timedelta(hours=n) for n in range(13)]
notifications_per_user: int
for user in User.objects.all():
notifications_per_user = len(
(
NotificationFactory(
recipient=user,
actor=user,
verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[0],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[1],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[2],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[3],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Bianca hat für den Auftrag Autoversicherung 3 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[4],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Bianca hat für den Auftrag Autoversicherung 1 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[5],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Bianca hat für den Auftrag Autoversicherung 2 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[6],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Bianca hat für den Auftrag Autoversicherung 4 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[7],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Chantal hat eine Bewertung für den Transferauftrag 3 eingegeben",
target_url="/",
actor_avatar_url=avatar_urls[2],
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[8],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Chantal hat eine Bewertung für den Transferauftrag 4 eingegeben",
target_url="/",
actor_avatar_url=avatar_urls[2],
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[9],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Super, du kommst in deinem Lernpfad gut voran. Schaue dir jetzt die verfügbaren Prüfungstermine an.",
target_url="/",
notification_type=NotificationType.PROGRESS,
course="Versicherungsvermittler/-in",
timestamp=timestamps[10],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Wartungsarbeiten: 20.12.2022 08:00 - 12:00",
notification_type=NotificationType.INFORMATION,
timestamp=timestamps[11],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Wartungsarbeiten: 31.01.2023 08:00 - 12:00",
notification_type=NotificationType.INFORMATION,
timestamp=timestamps[12],
),
)
)
return notifications_per_user

View File

@ -0,0 +1,11 @@
import djclick as click
from vbv_lernwelt.notify.create_default_notifications import (
create_default_notifications,
)
@click.command()
def command():
print("Creating default notifications")
create_default_notifications()

View File

@ -0,0 +1,124 @@
# Generated by Django 3.2.13 on 2023-02-08 08:43
import django.db.models.deletion
import django.utils.timezone
import jsonfield.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.CreateModel(
name="Notification",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"level",
models.CharField(
choices=[
("success", "success"),
("info", "info"),
("warning", "warning"),
("error", "error"),
],
default="info",
max_length=20,
),
),
("unread", models.BooleanField(db_index=True, default=True)),
("actor_object_id", models.CharField(max_length=255)),
("verb", models.CharField(max_length=255)),
("description", models.TextField(blank=True, null=True)),
(
"target_object_id",
models.CharField(blank=True, max_length=255, null=True),
),
(
"action_object_object_id",
models.CharField(blank=True, max_length=255, null=True),
),
(
"timestamp",
models.DateTimeField(
db_index=True, default=django.utils.timezone.now
),
),
("public", models.BooleanField(db_index=True, default=True)),
("deleted", models.BooleanField(db_index=True, default=False)),
("emailed", models.BooleanField(db_index=True, default=False)),
("data", jsonfield.fields.JSONField(blank=True, null=True)),
(
"notification_type",
models.CharField(
choices=[
("USER_INTERACTION", "User Interaction"),
("PROGRESS", "Progress"),
("INFORMATION", "Information"),
],
default="INFORMATION",
max_length=32,
),
),
("target_url", models.URLField(blank=True, null=True)),
("actor_avatar_url", models.URLField(blank=True, null=True)),
("course", models.CharField(blank=True, max_length=32, null=True)),
(
"action_object_content_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notify_action_object",
to="contenttypes.contenttype",
),
),
(
"actor_content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notify_actor",
to="contenttypes.contenttype",
),
),
(
"recipient",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"target_content_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notify_target",
to="contenttypes.contenttype",
),
),
],
options={
"ordering": ["-timestamp"],
"abstract": False,
"index_together": {("recipient", "unread")},
},
),
]

View File

@ -0,0 +1,24 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from notifications.base.models import AbstractNotification
class NotificationType(models.TextChoices):
USER_INTERACTION = "USER_INTERACTION", _("User Interaction")
PROGRESS = "PROGRESS", _("Progress")
INFORMATION = "INFORMATION", _("Information")
class Notification(AbstractNotification):
notification_type = models.CharField(
max_length=32,
choices=NotificationType.choices,
default=NotificationType.INFORMATION,
)
target_url = models.URLField(blank=True, null=True)
actor_avatar_url = models.URLField(blank=True, null=True)
course = models.CharField(max_length=32, blank=True, null=True)
class Meta(AbstractNotification.Meta):
abstract = False
ordering = ["-timestamp"]

View File

@ -0,0 +1,120 @@
import logging
from typing import Optional
from notifications.signals import notify
from sendgrid import Mail, SendGridAPIClient
from storages.utils import setting
from vbv_lernwelt.core.models import User
from vbv_lernwelt.notify.models import Notification, NotificationType
logger = logging.getLogger(__name__)
class EmailService:
_sendgrid_client = SendGridAPIClient(setting("SENDGRID_API_KEY"))
@classmethod
def send_email(cls, recipient: User, verb: str, target_url) -> bool:
message = Mail(
from_email="info@iterativ.ch",
to_emails=recipient.email,
subject=f"myVBV - {verb}",
## TODO: Add HTML content.
html_content=f"{verb}: <a href='{target_url}'>Link</a>",
)
try:
cls._sendgrid_client.send(message)
logger.info(f"Successfully sent email to {recipient}")
return True
except Exception as e:
logger.error(f"Failed to send email to {recipient}: {e}")
return False
class NotificationService:
@classmethod
def send_user_interaction_notification(
cls, recipient: User, verb: str, sender: User, course: str, target_url: str
) -> None:
cls._send_notification(
recipient=recipient,
verb=verb,
sender=sender,
course=course,
target_url=target_url,
notification_type=NotificationType.USER_INTERACTION,
)
@classmethod
def send_progress_notification(
cls, recipient: User, verb: str, course: str, target_url: str
) -> None:
cls._send_notification(
recipient=recipient,
verb=verb,
course=course,
target_url=target_url,
notification_type=NotificationType.PROGRESS,
)
@classmethod
def send_information_notification(
cls, recipient: User, verb: str, target_url: str
) -> None:
cls._send_notification(
recipient=recipient,
verb=verb,
target_url=target_url,
notification_type=NotificationType.INFORMATION,
)
@classmethod
def _send_notification(
cls,
recipient: User,
verb: str,
notification_type: NotificationType,
sender: Optional[User] = None,
course: Optional[str] = None,
target_url: Optional[str] = None,
) -> None:
actor_avatar_url: Optional[str] = None
if not sender:
sender = User.objects.get(email="admin")
else:
actor_avatar_url = sender.avatar_url
emailed = False
if cls._should_send_email(notification_type, recipient):
emailed = cls._send_email(recipient, verb, target_url)
response = notify.send(
sender=sender,
recipient=recipient,
verb=verb,
)
# Custom Notification model fields cannot be set using the notify.send() method.
# https://github.com/django-notifications/django-notifications/issues/301
sent_notification: Notification = response[0][1][0]
sent_notification.target_url = target_url
sent_notification.notification_type = notification_type
sent_notification.course = course
sent_notification.target_url = target_url
sent_notification.actor_avatar_url = actor_avatar_url
sent_notification.emailed = emailed
sent_notification.save()
@staticmethod
def _should_send_email(
notification_type: NotificationType, recipient: User
) -> bool:
return str(notification_type) in recipient.additional_json_data.get(
"email_notification_types", []
)
@staticmethod
def _send_email(recipient: User, verb: str, target_url: Optional[str]) -> bool:
return EmailService.send_email(
recipient=recipient,
verb=verb,
target_url=target_url,
)

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
#
# Iterativ GmbH
# http://www.iterativ.ch/
#
# Copyright (c) 2015 Iterativ GmbH. All rights reserved.
#
# Created on 2022-12-15
# @author: lorenz.padberg@iterativ.ch

View File

@ -0,0 +1,18 @@
import factory
from django.utils import timezone
from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.notify.models import Notification
class NotificationFactory(factory.django.DjangoModelFactory):
class Meta:
model = Notification
recipient = factory.SubFactory(UserFactory)
timestamp = factory.Faker(
"date_time_this_month",
tzinfo=timezone.get_current_timezone(),
)
actor = factory.SubFactory(UserFactory)
verb = "Elia hat einen neuen Beitrag erfasst"

View File

@ -0,0 +1,19 @@
from django.test import TestCase
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.notify.create_default_notifications import (
create_default_notifications,
)
from vbv_lernwelt.notify.models import Notification
class TestCreateDefaultNotifications(TestCase):
def test_create_default_notifications(self):
UserFactory(username="John Doe", email="john.doe@gmail.com")
UserFactory(username="Ruedi Hürzeler", email="ruediboy69@gmail.com")
notifications_per_user = create_default_notifications()
notifications = Notification.objects.all()
expected_count = User.objects.all().count() * notifications_per_user
self.assertEqual(len(notifications), expected_count)

View File

@ -0,0 +1,15 @@
from django.test import TestCase
from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.notify.tests.factories import NotificationFactory
class TestFactories(TestCase):
def test_create_notification(self):
notification = NotificationFactory()
self.assertIsNotNone(notification)
def test_create_notification_with_recipient(self):
recipient = UserFactory(username="John Doe", email="john.doe@gmail.com")
notification = NotificationFactory(recipient=recipient)
self.assertEqual(notification.recipient, recipient)

View File

@ -0,0 +1,139 @@
import json
from rest_framework.test import APITestCase
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.notify.models import Notification, NotificationType
from vbv_lernwelt.notify.tests.factories import NotificationFactory
class TestNotificationApi(APITestCase):
def setUp(self) -> None:
alice = UserFactory(username="Alice", email="alice@gmail.com")
john = UserFactory(username="John Doe", email="john.doe@gmail.com")
self.user = User.objects.get(username="Alice")
self.client.login(username="Alice", password="pw")
self.alice = alice
self.john = john
def create_default_notifications(self):
NotificationFactory(
recipient=self.john, verb="{} hat einen neuen Beitrag erfasst"
)
NotificationFactory(
recipient=self.john,
actor=self.alice,
verb="hat einen Tranverauftrag erstellt",
)
NotificationFactory(
recipient=self.alice,
actor=self.john,
verb="{} hat deinen Beitrag kommentiert",
unread=False,
)
NotificationFactory(
recipient=self.alice,
actor=self.john,
verb="{} ist ganz klein geworden",
unread=True,
)
def test_get_all_only_returns_logged_in_user_notification(self):
self.create_default_notifications()
response = self.client.get("/notifications/api/all_list/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["all_count"] < Notification.objects.count())
self.assertEqual(2, data["all_count"])
self.assertTrue(
all(
[
self.alice.id == notification["recipient"]
for notification in data["all_list"]
]
)
)
self.assertEqual("John Doe", data["all_list"][0]["actor"])
def test_get_all_pagination(self):
num_notifications = 322
for i in range(num_notifications):
NotificationFactory(
recipient=self.alice,
actor=self.john,
verb="{} ist ganz klein geworden",
unread=True,
)
response = self.client.get("/notifications/api/all_list/?max=10")
data = response.json()
self.assertEqual(num_notifications, data["all_count"])
self.assertEqual(len(data["all_list"]), 10)
def test_get_unread_pagination(self):
unread_notifications = 120
for i in range(unread_notifications):
NotificationFactory(
recipient=self.alice,
actor=self.john,
verb="{} ist ganz klein geworden",
unread=True,
)
while unread_notifications > 0:
to_read_at_once = 12
# Read to_read_at_once unread notifications at a time
data = self.client.get(
f"/notifications/api/unread_list/?max={to_read_at_once}&mark_as_read=true"
).json()
self.assertEqual(len(data["unread_list"]), to_read_at_once)
unread_notifications -= to_read_at_once
response = self.client.get("/notifications/api/unread_count/")
unread_count = response.json()["unread_count"]
self.assertEqual(unread_count, unread_notifications)
def test_unread_count(self):
self.create_default_notifications()
response = self.client.get("/notifications/api/unread_count/")
unread_count = response.json()["unread_count"]
self.assertEqual(unread_count, 1)
class TestNotificationSettingsApi(APITestCase):
def setUp(self) -> None:
username = "Alice"
UserFactory(username=username, email="alice@gmail.com")
self.user = User.objects.get(username=username)
self.client.login(username=username, password="pw")
def test_store_retrieve_settings(self):
notification_settings = json.dumps(
[NotificationType.INFORMATION, NotificationType.PROGRESS]
)
api_path = "/api/notify/email_notification_settings/"
response = self.client.post(
api_path,
notification_settings,
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), notification_settings)
self.user.refresh_from_db()
self.assertEqual(
self.user.additional_json_data["email_notification_types"],
notification_settings,
)
response = self.client.get(
api_path,
format="json",
)
self.assertEqual(response.json(), notification_settings)

Some files were not shown because too many files have changed in this diff Show More