Merge branch 'develop'
This commit is contained in:
commit
442e04dfe3
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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)"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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 }} - </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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface CheckboxItem<T> {
|
||||||
|
value: T;
|
||||||
|
label?: string;
|
||||||
|
checked: boolean;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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="
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
});
|
||||||
|
|
@ -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" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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...
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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,)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import vbv_lernwelt.feedback.models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import vbv_lernwelt.files.utils
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "vbv_lernwelt.notify"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
Loading…
Reference in New Issue