Merge branch 'develop'
This commit is contained in:
commit
442e04dfe3
|
|
@ -19,6 +19,7 @@ e2e: &e2e
|
|||
- source ./env/bitbucket/prepare_for_test.sh
|
||||
- npm run build
|
||||
- source vbvvenv/bin/activate
|
||||
- pip install -r server/requirements/requirements-dev.txt
|
||||
- ./prepare_server_cypress.sh --start-background
|
||||
- npm run cypress:ci
|
||||
artifacts:
|
||||
|
|
@ -28,7 +29,7 @@ e2e: &e2e
|
|||
pipelines:
|
||||
default:
|
||||
- step:
|
||||
name: install cypress dependencies
|
||||
name: install dependencies
|
||||
services:
|
||||
- postgres
|
||||
caches:
|
||||
|
|
@ -75,7 +76,7 @@ pipelines:
|
|||
- python -m venv vbvvenv
|
||||
- source vbvvenv/bin/activate
|
||||
- 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 .
|
||||
- ufmt check server
|
||||
- step:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"@sentry/vue": "^7.20.0",
|
||||
"@urql/vue": "^1.0.2",
|
||||
"d3": "^7.6.1",
|
||||
"dayjs": "^1.11.7",
|
||||
"graphql": "^16.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.8.0",
|
||||
|
|
@ -5467,6 +5468,11 @@
|
|||
"integrity": "sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==",
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
|
|
@ -15934,6 +15940,11 @@
|
|||
"integrity": "sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==",
|
||||
"dev": true
|
||||
},
|
||||
"dayjs": {
|
||||
"version": "1.11.7",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
|
||||
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
|
||||
},
|
||||
"de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"@sentry/vue": "^7.20.0",
|
||||
"@urql/vue": "^1.0.2",
|
||||
"d3": "^7.6.1",
|
||||
"dayjs": "^1.11.7",
|
||||
"graphql": "^16.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.8.0",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
<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";
|
||||
|
||||
log.debug("AppFooter created");
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
async function changeLocale(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
await loadLocaleMessages(target.value);
|
||||
setI18nLanguage(target.value);
|
||||
userStore.setUserLanguages(target.value as availableLanguages);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -22,9 +24,16 @@ async function changeLocale(event: Event) {
|
|||
<div class="lg:ml-8">Deutsch</div>
|
||||
<!--div class="locale-changer">
|
||||
<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>
|
||||
</div-->
|
||||
</div -->
|
||||
<div class="lg:ml-8">{{ $t("footer.contact") }}</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -157,12 +157,9 @@ const MAX_STEPS = 12;
|
|||
const sendFeedbackMutation = graphql(`
|
||||
mutation SendFeedbackMutation($input: SendFeedbackInput!) {
|
||||
sendFeedback(input: $input) {
|
||||
id
|
||||
satisfaction
|
||||
goalAttainment
|
||||
proficiency
|
||||
receivedMaterials
|
||||
materialsRating
|
||||
feedbackResponse {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
field
|
||||
messages
|
||||
|
|
@ -205,17 +202,19 @@ const sendFeedback = () => {
|
|||
return;
|
||||
}
|
||||
const input: SendFeedbackInput = reactive({
|
||||
materialsRating,
|
||||
courseNegativeFeedback,
|
||||
coursePositiveFeedback,
|
||||
goalAttainment,
|
||||
instructorCompetence,
|
||||
instructorRespect,
|
||||
instructorOpenFeedback,
|
||||
satisfaction,
|
||||
proficiency,
|
||||
receivedMaterials,
|
||||
wouldRecommend,
|
||||
data: {
|
||||
materials_rating: materialsRating,
|
||||
course_negative_feedback: courseNegativeFeedback,
|
||||
course_positive_feedback: coursePositiveFeedback,
|
||||
goald_attainment: goalAttainment,
|
||||
instructor_competence: instructorCompetence,
|
||||
instructor_respect: instructorRespect,
|
||||
instructor_open_feedback: instructorOpenFeedback,
|
||||
satisfaction,
|
||||
proficiency,
|
||||
received_materials: receivedMaterials,
|
||||
would_recommend: wouldRecommend,
|
||||
},
|
||||
page: props.page.translation_key,
|
||||
courseSession: courseSession.id,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import log from "loglevel";
|
|||
import IconLogout from "@/components/icons/IconLogout.vue";
|
||||
import IconSettings from "@/components/icons/IconSettings.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 { useAppStore } from "@/stores/app";
|
||||
import { useCourseSessionsStore } from "@/stores/courseSessions";
|
||||
import { useNotificationsStore } from "@/stores/notifications";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import type { DropdownListItem } from "@/types";
|
||||
import type { Component } from "vue";
|
||||
|
|
@ -14,7 +17,7 @@ import { onMounted, reactive } from "vue";
|
|||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
type DropdownActions = "logout" | "settings";
|
||||
type DropdownActions = "logout" | "settings" | "profile";
|
||||
|
||||
interface DropdownData {
|
||||
action: DropdownActions;
|
||||
|
|
@ -27,6 +30,7 @@ const router = useRouter();
|
|||
const userStore = useUserStore();
|
||||
const appStore = useAppStore();
|
||||
const courseSessionsStore = useCourseSessionsStore();
|
||||
const notificationsStore = useNotificationsStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
const state = reactive({ showMenu: false });
|
||||
|
|
@ -60,9 +64,12 @@ function inMediaLibrary() {
|
|||
|
||||
function handleDropdownSelect(data: DropdownData) {
|
||||
switch (data.action) {
|
||||
case "settings":
|
||||
case "profile":
|
||||
router.push("/profile");
|
||||
break;
|
||||
case "settings":
|
||||
router.push("/settings");
|
||||
break;
|
||||
case "logout":
|
||||
userStore.handleLogout();
|
||||
break;
|
||||
|
|
@ -85,7 +92,14 @@ onMounted(() => {
|
|||
|
||||
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,
|
||||
data: {
|
||||
action: "settings",
|
||||
|
|
@ -127,14 +141,23 @@ const profileDropdownData: DropdownListItem[] = [
|
|||
</div>
|
||||
|
||||
<div class="flex items-center lg:hidden">
|
||||
<router-link
|
||||
v-if="userStore.loggedIn"
|
||||
to="/messages"
|
||||
class="nav-item flex flex-row items-center"
|
||||
data-cy="messages-link"
|
||||
>
|
||||
<it-icon-bell class="mr-6 h-6 w-6" />
|
||||
</router-link>
|
||||
<div v-if="userStore.loggedIn" class="mr-6 flex flex-row items-center">
|
||||
<NotificationPopover>
|
||||
<template #toggleButtonContent>
|
||||
<div class="nav-item flex">
|
||||
<it-icon-notification class="h-6 w-6" />
|
||||
<div
|
||||
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
|
||||
v-if="userStore.loggedIn"
|
||||
to="/messages"
|
||||
|
|
@ -218,13 +241,23 @@ const profileDropdownData: DropdownListItem[] = [
|
|||
>
|
||||
{{ $t("mediaLibrary.title") }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/messages"
|
||||
class="nav-item flex flex-row items-center"
|
||||
data-cy="messages-link"
|
||||
>
|
||||
<it-icon-bell class="mr-6 h-6 w-6" />
|
||||
</router-link>
|
||||
<div v-if="userStore.loggedIn" class="mr-6 flex items-center">
|
||||
<NotificationPopover>
|
||||
<template #toggleButtonContent>
|
||||
<div class="nav-item flex">
|
||||
<it-icon-notification class="h-6 w-6" />
|
||||
<div
|
||||
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
|
||||
to="/messages"
|
||||
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>
|
||||
<button
|
||||
class="mt-2 inline-block flex items-center"
|
||||
@click="clickLink('/profile')"
|
||||
@click="clickLink('/settings')"
|
||||
>
|
||||
<IconSettings class="inline-block" />
|
||||
<span class="ml-3">{{ $t("mainNavigation.settings") }}</span>
|
||||
<span class="ml-3">{{ $t("general.settings") }}</span>
|
||||
</button>
|
||||
</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>
|
||||
<ItCheckbox
|
||||
v-else
|
||||
:checked="learningContent.completion_status === 'success'"
|
||||
:checkbox_item="{
|
||||
value: learningContent.completion_status,
|
||||
checked: learningContent.completion_status === 'success',
|
||||
}"
|
||||
:data-cy="`${learningContent.slug}-checkbox`"
|
||||
@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">
|
||||
import type { CheckboxItem } from "@/components/ui/checkbox.types";
|
||||
import log from "loglevel";
|
||||
|
||||
interface Props {
|
||||
checked?: boolean;
|
||||
const props = defineProps<{
|
||||
checkbox_item: CheckboxItem<any>;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
label: undefined,
|
||||
});
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["toggle"]);
|
||||
const toggle = () => {
|
||||
|
|
@ -42,7 +36,7 @@ const input = (e: Event) => {
|
|||
<label
|
||||
class="cy-checkbox cy-checkbox-checked block flex h-8 items-center bg-contain bg-no-repeat pl-8 disabled:opacity-50"
|
||||
: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-unchecked.svg)] hover:bg-[url(/static/icons/icon-checkbox-unchecked-hover.svg)]'
|
||||
"
|
||||
|
|
@ -51,18 +45,23 @@ const input = (e: Event) => {
|
|||
>
|
||||
<input
|
||||
ref="checkbox"
|
||||
:checked="checked"
|
||||
:value="checked"
|
||||
:checked="checkbox_item.checked"
|
||||
:value="checkbox_item.value"
|
||||
:disabled="disabled"
|
||||
:data-cy="`it-checkbox-${checkbox_item.value}`"
|
||||
class="sr-only"
|
||||
type="checkbox"
|
||||
@keydown="keydown"
|
||||
@input="input"
|
||||
/>
|
||||
|
||||
<span v-if="label" class="ml-4">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="ml-4 flex-col">
|
||||
<div v-if="checkbox_item.label">
|
||||
{{ checkbox_item.label }}
|
||||
</div>
|
||||
<div v-if="checkbox_item.subtitle" class="text-gray-900">
|
||||
{{ checkbox_item.subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</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">
|
||||
import type { RadioItem } from "@/components/learningPath/feedback.types";
|
||||
|
||||
import type { CheckboxItem } from "@/components/ui/checkbox.types";
|
||||
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
|
||||
|
||||
defineProps<{
|
||||
modelValue: any;
|
||||
items: RadioItem<any>[];
|
||||
label: string;
|
||||
const props = defineProps<{
|
||||
items: CheckboxItem<any>[];
|
||||
label: string | undefined;
|
||||
}>();
|
||||
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>
|
||||
|
||||
<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";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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"
|
||||
): 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"];
|
||||
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 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) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type Scalars = {
|
|||
Int: number;
|
||||
Float: number;
|
||||
DateTime: any;
|
||||
GenericScalar: any;
|
||||
JSONString: any;
|
||||
PositiveInt: any;
|
||||
UUID: any;
|
||||
|
|
@ -152,6 +153,11 @@ export type CircleSiblingsArgs = {
|
|||
searchQuery?: InputMaybe<Scalars["String"]>;
|
||||
};
|
||||
|
||||
export type CircleDocument = {
|
||||
__typename?: "CircleDocument";
|
||||
id?: Maybe<Scalars["ID"]>;
|
||||
};
|
||||
|
||||
export type CollectionObjectType = {
|
||||
__typename?: "CollectionObjectType";
|
||||
ancestors: Array<Maybe<CollectionObjectType>>;
|
||||
|
|
@ -520,6 +526,14 @@ export type ErrorType = {
|
|||
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 & {
|
||||
__typename?: "FloatBlock";
|
||||
blockType: Scalars["String"];
|
||||
|
|
@ -1170,6 +1184,10 @@ export type MutationSendFeedbackArgs = {
|
|||
input: SendFeedbackInput;
|
||||
};
|
||||
|
||||
export type Node = {
|
||||
id: Scalars["ID"];
|
||||
};
|
||||
|
||||
export type Page = PageInterface & {
|
||||
__typename?: "Page";
|
||||
aliasOf?: Maybe<Page>;
|
||||
|
|
@ -1581,6 +1599,7 @@ export type RichTextBlock = StreamFieldInterface & {
|
|||
|
||||
export type Search =
|
||||
| Circle
|
||||
| CircleDocument
|
||||
| CompetencePage
|
||||
| CompetenceProfilePage
|
||||
| Course
|
||||
|
|
@ -1609,37 +1628,16 @@ export type SecurityRequestResponseLog = {
|
|||
|
||||
export type SendFeedbackInput = {
|
||||
clientMutationId?: InputMaybe<Scalars["String"]>;
|
||||
courseNegativeFeedback?: InputMaybe<Scalars["String"]>;
|
||||
coursePositiveFeedback?: InputMaybe<Scalars["String"]>;
|
||||
goalAttainment?: InputMaybe<Scalars["Int"]>;
|
||||
id?: InputMaybe<Scalars["Int"]>;
|
||||
instructorCompetence?: InputMaybe<Scalars["Int"]>;
|
||||
instructorOpenFeedback?: InputMaybe<Scalars["String"]>;
|
||||
instructorRespect?: InputMaybe<Scalars["Int"]>;
|
||||
materialsRating?: InputMaybe<Scalars["Int"]>;
|
||||
courseSession: Scalars["Int"];
|
||||
data?: InputMaybe<Scalars["GenericScalar"]>;
|
||||
page: Scalars["String"];
|
||||
proficiency?: InputMaybe<Scalars["Int"]>;
|
||||
receivedMaterials?: InputMaybe<Scalars["Boolean"]>;
|
||||
satisfaction?: InputMaybe<Scalars["Int"]>;
|
||||
wouldRecommend?: InputMaybe<Scalars["Boolean"]>;
|
||||
};
|
||||
|
||||
export type SendFeedbackPayload = {
|
||||
__typename?: "SendFeedbackPayload";
|
||||
clientMutationId?: Maybe<Scalars["String"]>;
|
||||
courseNegativeFeedback?: Maybe<Scalars["String"]>;
|
||||
coursePositiveFeedback?: Maybe<Scalars["String"]>;
|
||||
errors?: Maybe<Array<Maybe<ErrorType>>>;
|
||||
goalAttainment?: Maybe<Scalars["Int"]>;
|
||||
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"]>;
|
||||
feedbackResponse?: Maybe<FeedbackResponse>;
|
||||
};
|
||||
|
||||
export type SiteObjectType = {
|
||||
|
|
@ -1851,12 +1849,7 @@ export type SendFeedbackMutationMutation = {
|
|||
__typename?: "Mutation";
|
||||
sendFeedback?: {
|
||||
__typename?: "SendFeedbackPayload";
|
||||
id?: number | null;
|
||||
satisfaction?: number | null;
|
||||
goalAttainment?: number | null;
|
||||
proficiency?: number | null;
|
||||
receivedMaterials?: boolean | null;
|
||||
materialsRating?: number | null;
|
||||
feedbackResponse?: { __typename?: "FeedbackResponse"; id: string } | null;
|
||||
errors?: Array<{
|
||||
__typename?: "ErrorType";
|
||||
field: string;
|
||||
|
|
@ -1901,12 +1894,16 @@ export const SendFeedbackMutationDocument = {
|
|||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "id" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "satisfaction" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "goalAttainment" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "proficiency" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "receivedMaterials" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "materialsRating" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "feedbackResponse" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "id" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "errors" },
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import dayjs from "dayjs";
|
||||
import { nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
|
|
@ -5,9 +6,12 @@ import { createI18n } from "vue-i18n";
|
|||
export const SUPPORT_LOCALES = ["de", "fr", "it"];
|
||||
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);
|
||||
setI18nLanguage(options.locale);
|
||||
dayjs.locale(options.locale);
|
||||
return i18n;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"back": "zurück",
|
||||
"backCapitalized": "@.capitalize:general.back",
|
||||
"save": "Speichern",
|
||||
"send": "Senden",
|
||||
"learningUnit": "Lerneinheit",
|
||||
"learningPath": "Lernpfad",
|
||||
"learningSequence": "Lernsequenz",
|
||||
|
|
@ -22,11 +23,13 @@
|
|||
"profileLink": "Profil anzeigen",
|
||||
"shop": "Shop",
|
||||
"yes": "Ja",
|
||||
"no": "Nein"
|
||||
"no": "Nein",
|
||||
"showAll": "Alle anschauen",
|
||||
"settings": "Kontoeinstellungen"
|
||||
},
|
||||
"mainNavigation": {
|
||||
"logout": "Abmelden",
|
||||
"settings": "Kontoeinstellungen"
|
||||
"profile": "Profil"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Willkommen, {name}"
|
||||
|
|
@ -83,7 +86,6 @@
|
|||
"competences": "Kompetenzen",
|
||||
"title": "KompetenzNavi",
|
||||
"lastImprovements": "Letzte verbesserte Kompetenzen",
|
||||
"showAll": "Alle anschauen",
|
||||
"assessment": "Einschätzungen",
|
||||
"notAssessed": "Nicht eingeschätzt",
|
||||
"assessAgain": "Sich nochmals einschätzen"
|
||||
|
|
@ -111,7 +113,9 @@
|
|||
"feedbacksDone": "Abgeschickte Feedbacks von Teilnehmer.",
|
||||
"examsDone": "Abgelegte Prüfungen von Teilnehmer.",
|
||||
"progress": "Fortschritt",
|
||||
"profileLink": "Profil anzeigen"
|
||||
"profileLink": "Profil anzeigen",
|
||||
"notifyTaskDescription": "Teilnehmer benachrichtigen",
|
||||
"notifyTask": "Benachrichtigen"
|
||||
},
|
||||
"messages": {
|
||||
"sendMessage": "Nachricht schreiben"
|
||||
|
|
@ -148,6 +152,13 @@
|
|||
"answers": "Antworten",
|
||||
"noFeedbacks": "Es wurden noch keine Feedbacks abgegeben"
|
||||
},
|
||||
"notifications": {
|
||||
"load_more": "Mehr laden",
|
||||
"no_notifications": "Du hast derzeit keine Benachrichtigungen"
|
||||
},
|
||||
"settings": {
|
||||
"emailNotifications": "Email Benachrichtigungen"
|
||||
},
|
||||
"constants": {
|
||||
"yes": "Ja",
|
||||
"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");
|
||||
} else {
|
||||
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">
|
||||
import IconLogout from "@/components/icons/IconLogout.vue";
|
||||
import IconSettings from "@/components/icons/IconSettings.vue";
|
||||
import LearningPathCircle from "@/components/learningPath/LearningPathCircle.vue";
|
||||
import HorizontalBarChart from "@/components/ui/HorizontalBarChart.vue";
|
||||
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
|
||||
import ItCheckboxGroup from "@/components/ui/ItCheckboxGroup.vue";
|
||||
|
|
@ -137,6 +138,11 @@ function log(data: any) {
|
|||
<it-icon-message class="it-icon" />
|
||||
</div>
|
||||
|
||||
<div class="inline-flex flex-col">
|
||||
notification
|
||||
<it-icon-notification class="it-icon" />
|
||||
</div>
|
||||
|
||||
<div class="inline-flex flex-col">
|
||||
arrow-up
|
||||
<it-icon-arrow-up />
|
||||
|
|
@ -407,14 +413,56 @@ function log(data: any) {
|
|||
<h2 class="mt-8 mb-8">Checkbox</h2>
|
||||
|
||||
<ItCheckbox
|
||||
:checked="state.checkboxValue"
|
||||
:disabled="false"
|
||||
:checkbox_item="{
|
||||
subtitle: 'Subtitle',
|
||||
label: 'Label',
|
||||
value: 'value',
|
||||
checked: false,
|
||||
}"
|
||||
@toggle="state.checkboxValue = !state.checkboxValue"
|
||||
>
|
||||
Label
|
||||
</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>
|
||||
|
||||
|
|
@ -429,12 +477,7 @@ function log(data: any) {
|
|||
</ItDropdown>
|
||||
</div>
|
||||
<ItTextarea v-model="textValue" label="Hallo Velo" class="mb-8" />
|
||||
<ItCheckboxGroup
|
||||
v-model="sourceValues"
|
||||
:label="sourceLabel"
|
||||
:items="sourceItems"
|
||||
class="mb-8"
|
||||
/>
|
||||
|
||||
<ItRadioGroup
|
||||
v-model="satisfaction"
|
||||
:label="satisfactionText"
|
||||
|
|
@ -454,6 +497,26 @@ function log(data: any) {
|
|||
/>
|
||||
<HorizontalBarChart title="Frage X" text="Fragentext" :items="barChartItems" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -151,9 +151,9 @@ function setActiveClasses(translationKey: string) {
|
|||
>
|
||||
<ul class="w-full">
|
||||
<li
|
||||
class="flex h-12 items-center justify-between"
|
||||
v-for="(circle, i) of cockpitStore.selectedCircles"
|
||||
:key="i"
|
||||
class="flex h-12 items-center justify-between"
|
||||
>
|
||||
<LearningPathDiagram
|
||||
v-if="
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ const countStatus = computed(() => {
|
|||
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/competences`"
|
||||
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>
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
@ -137,7 +137,7 @@ const countStatus = computed(() => {
|
|||
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/criteria`"
|
||||
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>
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
@ -165,7 +165,7 @@ const countStatus = computed(() => {
|
|||
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/criteria`"
|
||||
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>
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -138,6 +138,14 @@ const router = createRouter({
|
|||
path: "/profile",
|
||||
component: () => import("@/pages/ProfilePage.vue"),
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
component: () => import("@/pages/SettingsPage.vue"),
|
||||
},
|
||||
{
|
||||
path: "/notifications",
|
||||
component: () => import("@/pages/NotificationsPage.vue"),
|
||||
},
|
||||
{
|
||||
path: "/styleguide",
|
||||
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 { bustItGetCache, itGetCached, itPost } from "@/fetchHelpers";
|
||||
import { loadLocaleMessages, setI18nLanguage } from "@/i18n";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
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
|
||||
|
||||
export type availableLanguages = "de" | "fr" | "it";
|
||||
|
||||
export type UserState = {
|
||||
id: number;
|
||||
first_name: string;
|
||||
|
|
@ -17,6 +20,7 @@ export type UserState = {
|
|||
is_superuser: boolean;
|
||||
course_session_experts: number[];
|
||||
loggedIn: boolean;
|
||||
language: availableLanguages;
|
||||
};
|
||||
|
||||
const initialUserState: UserState = {
|
||||
|
|
@ -29,8 +33,14 @@ const initialUserState: UserState = {
|
|||
is_superuser: false,
|
||||
course_session_experts: [],
|
||||
loggedIn: false,
|
||||
language: "de",
|
||||
};
|
||||
|
||||
async function setLocale(language: availableLanguages) {
|
||||
await loadLocaleMessages(language);
|
||||
setI18nLanguage(language);
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore({
|
||||
id: "user",
|
||||
state: () => initialUserState as UserState,
|
||||
|
|
@ -80,6 +90,12 @@ export const useUserStore = defineStore({
|
|||
this.$state = data;
|
||||
this.loggedIn = 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;
|
||||
};
|
||||
}
|
||||
|
||||
// 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_prod.env
|
||||
encrypted: env_secrets/caprover_stage.env
|
||||
encrypted: env_secrets/local_chrigu.env
|
||||
encrypted: env_secrets/local_daniel.env
|
||||
encrypted: env_secrets/local_lorenz.env
|
||||
encrypted: env_secrets/production.env
|
||||
encrypted: env_secrets/caprover_dev.env
|
||||
encrypted: env_secrets/caprover_prod.env
|
||||
encrypted: env_secrets/caprover_stage.env
|
||||
encrypted: env_secrets/local_chrigu.env
|
||||
encrypted: env_secrets/local_daniel.env
|
||||
encrypted: env_secrets/local_elia.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 create_default_users
|
||||
python server/manage.py create_default_courses
|
||||
python server/manage.py create_default_notifications
|
||||
|
||||
# make django translations
|
||||
(cd server && python manage.py compilemessages)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
Base settings to build other settings files upon.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
|
@ -101,6 +100,7 @@ THIRD_PARTY_APPS = [
|
|||
"storages",
|
||||
"grapple",
|
||||
"graphene_django",
|
||||
"notifications",
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
|
|
@ -112,6 +112,7 @@ LOCAL_APPS = [
|
|||
"vbv_lernwelt.media_library",
|
||||
"vbv_lernwelt.feedback",
|
||||
"vbv_lernwelt.files",
|
||||
"vbv_lernwelt.notify",
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-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_I18N_ENABLED = True
|
||||
WAGTAILADMIN_BASE_URL = "/server/cms/"
|
||||
|
||||
LANGUAGES = [
|
||||
("en-US", "English (American)"),
|
||||
|
|
@ -573,6 +575,13 @@ GRAPPLE = {
|
|||
"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
|
||||
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"),
|
||||
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.urls.static import static
|
||||
from django.contrib import admin
|
||||
|
|
@ -36,6 +37,7 @@ from vbv_lernwelt.feedback.views import (
|
|||
get_expert_feedbacks_for_course,
|
||||
get_feedback_for_circle,
|
||||
)
|
||||
from vbv_lernwelt.notify.views import email_notification_settings
|
||||
from wagtail import urls as wagtail_urls
|
||||
from wagtail.admin import urls as wagtailadmin_urls
|
||||
from wagtail.documents import urls as wagtaildocs_urls
|
||||
|
|
@ -66,6 +68,13 @@ urlpatterns = [
|
|||
name='vue_login'),
|
||||
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
|
||||
re_path(r"server/core/icons/$", generate_web_component_icons,
|
||||
name="generate_web_component_icons"),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ anyascii==0.3.1
|
|||
# via wagtail
|
||||
anyio==3.5.0
|
||||
# via watchfiles
|
||||
appnope==0.1.3
|
||||
# via ipython
|
||||
argon2-cffi==21.3.0
|
||||
# via -r requirements.in
|
||||
argon2-cffi-bindings==21.2.0
|
||||
|
|
@ -103,6 +105,7 @@ django==3.2.13
|
|||
# django-filter
|
||||
# django-model-utils
|
||||
# django-modelcluster
|
||||
# django-notifications-hq
|
||||
# django-permissionedforms
|
||||
# django-redis
|
||||
# django-storages
|
||||
|
|
@ -114,6 +117,7 @@ django==3.2.13
|
|||
# djangorestframework
|
||||
# drf-spectacular
|
||||
# graphene-django
|
||||
# jsonfield
|
||||
# wagtail
|
||||
# wagtail-grapple
|
||||
# wagtail-localize
|
||||
|
|
@ -134,9 +138,13 @@ django-filter==21.1
|
|||
django-ipware==4.0.2
|
||||
# via -r requirements.in
|
||||
django-model-utils==4.2.0
|
||||
# via -r requirements.in
|
||||
# via
|
||||
# -r requirements.in
|
||||
# django-notifications-hq
|
||||
django-modelcluster==6.0
|
||||
# via wagtail
|
||||
django-notifications-hq==1.7.0
|
||||
# via -r requirements.in
|
||||
django-permissionedforms==0.1
|
||||
# via wagtail
|
||||
django-ratelimit==3.0.1
|
||||
|
|
@ -242,6 +250,8 @@ jmespath==1.0.1
|
|||
# via
|
||||
# boto3
|
||||
# botocore
|
||||
jsonfield==3.1.0
|
||||
# via django-notifications-hq
|
||||
jsonschema==4.4.0
|
||||
# via drf-spectacular
|
||||
l18n==2021.3
|
||||
|
|
@ -370,6 +380,8 @@ python-dotenv==0.20.0
|
|||
# via
|
||||
# environs
|
||||
# uvicorn
|
||||
python-http-client==3.3.7
|
||||
# via sendgrid
|
||||
python-json-logger==2.0.2
|
||||
# via -r requirements.in
|
||||
python-slugify==6.1.1
|
||||
|
|
@ -379,6 +391,7 @@ pytz==2022.1
|
|||
# -r requirements.in
|
||||
# django
|
||||
# django-modelcluster
|
||||
# django-notifications-hq
|
||||
# djangorestframework
|
||||
# l18n
|
||||
pyyaml==6.0
|
||||
|
|
@ -400,6 +413,8 @@ rx==1.6.1
|
|||
# via graphql-core
|
||||
s3transfer==0.6.0
|
||||
# via boto3
|
||||
sendgrid==6.9.7
|
||||
# via -r requirements.in
|
||||
sentry-sdk==1.5.8
|
||||
# via -r requirements.in
|
||||
singledispatch==3.7.0
|
||||
|
|
@ -430,10 +445,14 @@ sqlparse==0.4.2
|
|||
# django-debug-toolbar
|
||||
stack-data==0.2.0
|
||||
# via ipython
|
||||
starkbank-ecdsa==2.2.0
|
||||
# via sendgrid
|
||||
stdlibs==2022.6.8
|
||||
# via usort
|
||||
structlog==21.5.0
|
||||
# via -r requirements.in
|
||||
swapper==1.3.0
|
||||
# via django-notifications-hq
|
||||
tablib[xls,xlsx]==3.2.1
|
||||
# via wagtail
|
||||
telepath==0.2
|
||||
|
|
|
|||
|
|
@ -24,10 +24,12 @@ django-ratelimit
|
|||
django-ipware
|
||||
django-csp
|
||||
django-storages
|
||||
django-notifications-hq
|
||||
|
||||
psycopg2-binary
|
||||
gunicorn
|
||||
sentry-sdk
|
||||
sendgrid
|
||||
|
||||
structlog
|
||||
python-json-logger
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ django==3.2.13
|
|||
# django-filter
|
||||
# django-model-utils
|
||||
# django-modelcluster
|
||||
# django-notifications-hq
|
||||
# django-permissionedforms
|
||||
# django-redis
|
||||
# django-storages
|
||||
|
|
@ -71,6 +72,7 @@ django==3.2.13
|
|||
# djangorestframework
|
||||
# drf-spectacular
|
||||
# graphene-django
|
||||
# jsonfield
|
||||
# wagtail
|
||||
# wagtail-grapple
|
||||
# wagtail-localize
|
||||
|
|
@ -85,9 +87,13 @@ django-filter==21.1
|
|||
django-ipware==4.0.2
|
||||
# via -r requirements.in
|
||||
django-model-utils==4.2.0
|
||||
# via -r requirements.in
|
||||
# via
|
||||
# -r requirements.in
|
||||
# django-notifications-hq
|
||||
django-modelcluster==6.0
|
||||
# via wagtail
|
||||
django-notifications-hq==1.7.0
|
||||
# via -r requirements.in
|
||||
django-permissionedforms==0.1
|
||||
# via wagtail
|
||||
django-ratelimit==3.0.1
|
||||
|
|
@ -147,6 +153,8 @@ jmespath==1.0.1
|
|||
# via
|
||||
# boto3
|
||||
# botocore
|
||||
jsonfield==3.1.0
|
||||
# via django-notifications-hq
|
||||
jsonschema==4.4.0
|
||||
# via drf-spectacular
|
||||
l18n==2021.3
|
||||
|
|
@ -188,6 +196,8 @@ python-dotenv==0.20.0
|
|||
# via
|
||||
# environs
|
||||
# uvicorn
|
||||
python-http-client==3.3.7
|
||||
# via sendgrid
|
||||
python-json-logger==2.0.2
|
||||
# via -r requirements.in
|
||||
python-slugify==6.1.1
|
||||
|
|
@ -197,6 +207,7 @@ pytz==2022.1
|
|||
# -r requirements.in
|
||||
# django
|
||||
# django-modelcluster
|
||||
# django-notifications-hq
|
||||
# djangorestframework
|
||||
# l18n
|
||||
pyyaml==6.0
|
||||
|
|
@ -213,6 +224,8 @@ rx==1.6.1
|
|||
# via graphql-core
|
||||
s3transfer==0.6.0
|
||||
# via boto3
|
||||
sendgrid==6.9.7
|
||||
# via -r requirements.in
|
||||
sentry-sdk==1.5.8
|
||||
# via -r requirements.in
|
||||
singledispatch==3.7.0
|
||||
|
|
@ -234,8 +247,12 @@ soupsieve==2.3.2.post1
|
|||
# via beautifulsoup4
|
||||
sqlparse==0.4.2
|
||||
# via django
|
||||
starkbank-ecdsa==2.2.0
|
||||
# via sendgrid
|
||||
structlog==21.5.0
|
||||
# via -r requirements.in
|
||||
swapper==1.3.0
|
||||
# via django-notifications-hq
|
||||
tablib[xls,xlsx]==3.2.1
|
||||
# via wagtail
|
||||
telepath==0.2
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ type Circle implements PageInterface {
|
|||
ancestors(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface!]!
|
||||
}
|
||||
|
||||
type CircleDocument {
|
||||
id: ID
|
||||
}
|
||||
|
||||
type CollectionObjectType {
|
||||
id: ID!
|
||||
path: String!
|
||||
|
|
@ -281,6 +285,13 @@ type ErrorType {
|
|||
messages: [String!]!
|
||||
}
|
||||
|
||||
type FeedbackResponse implements Node {
|
||||
id: ID!
|
||||
data: GenericScalar
|
||||
circle: Circle!
|
||||
courseSession: CourseSession!
|
||||
}
|
||||
|
||||
type FloatBlock implements StreamFieldInterface {
|
||||
id: String
|
||||
blockType: String!
|
||||
|
|
@ -289,6 +300,8 @@ type FloatBlock implements StreamFieldInterface {
|
|||
value: Float!
|
||||
}
|
||||
|
||||
scalar GenericScalar
|
||||
|
||||
type ImageChooserBlock implements StreamFieldInterface {
|
||||
id: String
|
||||
blockType: String!
|
||||
|
|
@ -608,6 +621,10 @@ type Mutation {
|
|||
sendFeedback(input: SendFeedbackInput!): SendFeedbackPayload
|
||||
}
|
||||
|
||||
interface Node {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type Page implements PageInterface {
|
||||
id: ID
|
||||
path: String!
|
||||
|
|
@ -783,42 +800,21 @@ type RichTextBlock implements StreamFieldInterface {
|
|||
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 {
|
||||
id: ID
|
||||
}
|
||||
|
||||
input SendFeedbackInput {
|
||||
id: Int
|
||||
page: String!
|
||||
satisfaction: Int
|
||||
goalAttainment: Int
|
||||
proficiency: Int
|
||||
receivedMaterials: Boolean
|
||||
materialsRating: Int
|
||||
instructorCompetence: Int
|
||||
instructorRespect: Int
|
||||
instructorOpenFeedback: String
|
||||
wouldRecommend: Boolean
|
||||
coursePositiveFeedback: String
|
||||
courseNegativeFeedback: String
|
||||
courseSession: Int!
|
||||
data: GenericScalar
|
||||
clientMutationId: String
|
||||
}
|
||||
|
||||
type SendFeedbackPayload {
|
||||
id: Int
|
||||
satisfaction: Int
|
||||
goalAttainment: Int
|
||||
proficiency: Int
|
||||
receivedMaterials: Boolean
|
||||
materialsRating: Int
|
||||
instructorCompetence: Int
|
||||
instructorRespect: Int
|
||||
instructorOpenFeedback: String
|
||||
wouldRecommend: Boolean
|
||||
coursePositiveFeedback: String
|
||||
courseNegativeFeedback: String
|
||||
feedbackResponse: FeedbackResponse
|
||||
errors: [ErrorType]
|
||||
clientMutationId: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("sites", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ def update_site_backward(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("sites", "0002_alter_domain_unique")]
|
||||
|
||||
operations = [migrations.RunPython(update_site_forward, update_site_backward)]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("sites", "0003_set_site_domain_and_name"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import djclick as click
|
||||
|
||||
from vbv_lernwelt.course.models import CourseCompletion
|
||||
from vbv_lernwelt.notify.models import Notification
|
||||
|
||||
|
||||
@click.command()
|
||||
def command():
|
||||
print("cypress reset data")
|
||||
CourseCompletion.objects.all().delete()
|
||||
Notification.objects.all().delete()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
|
@ -22,4 +21,13 @@ class Migration(migrations.Migration):
|
|||
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):
|
||||
|
||||
dependencies = [
|
||||
("core", "0003_alter_user_managers"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import migrations
|
||||
|
||||
from vbv_lernwelt.core.create_default_users import create_default_users
|
||||
from vbv_lernwelt.core.models import User
|
||||
|
||||
|
||||
def create_users(apps, schema_editor):
|
||||
default_password = "ACEEs0DCmNaPxdoNV8vhccuCTRl9b"
|
||||
if settings.APP_ENVIRONMENT == "development":
|
||||
default_password = None
|
||||
User = apps.get_model("core", "User")
|
||||
Group = apps.get_model("auth", "Group")
|
||||
create_default_users(
|
||||
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,
|
||||
"""
|
||||
|
||||
LANGUAGE_CHOICES = (
|
||||
("de", "Deutsch"),
|
||||
("fr", "Français"),
|
||||
("it", "Italiano"),
|
||||
)
|
||||
|
||||
# FIXME: look into it...
|
||||
# objects = UserManager()
|
||||
avatar_url = models.CharField(max_length=254, blank=True, default="")
|
||||
|
|
@ -18,6 +24,8 @@ class User(AbstractUser):
|
|||
sso_id = models.UUIDField(
|
||||
"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()
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
"avatar_url",
|
||||
"is_superuser",
|
||||
"course_session_experts",
|
||||
"language",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"is_superuser",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"username",
|
||||
]
|
||||
|
||||
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):
|
||||
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(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"])
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ def command():
|
|||
)
|
||||
|
||||
# initial completion data
|
||||
for (slug, status, email) in [
|
||||
for slug, status, email in [
|
||||
(
|
||||
"überbetriebliche-kurse-competence-crit-a21-allgemein",
|
||||
"success",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("course", "0001_initial"),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("course", "0002_auto_20221014_0933"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0008_alter_learningcontent_contents"),
|
||||
("course", "0003_alter_coursepage_course"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0008_alter_learningcontent_contents"),
|
||||
("course", "0004_coursesessionuser_expert"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("course", "0005_alter_coursesessionuser_expert"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("course", "0006_course_slug"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from factory import Dict
|
||||
from factory.django import DjangoModelFactory
|
||||
from factory.fuzzy import FuzzyChoice, FuzzyInteger
|
||||
|
||||
|
|
@ -5,33 +6,37 @@ from vbv_lernwelt.feedback.models import FeedbackResponse
|
|||
|
||||
|
||||
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:
|
||||
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
|
||||
from graphene_django.rest_framework.mutation import SerializerMutation
|
||||
import structlog
|
||||
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):
|
||||
class Meta:
|
||||
serializer_class = FeedbackResponseSerializer
|
||||
model_operations = ["create"]
|
||||
# https://medium.com/open-graphql/jsonfield-models-in-graphene-django-308ae43d14ee
|
||||
class SendFeedback(ClientIDMutation):
|
||||
feedback_response = Field(FeedbackResponseType)
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
from graphene.relay import Node
|
||||
from graphene.types.generic import GenericScalar
|
||||
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 Meta:
|
||||
# model = Feedback
|
||||
class FeedbackResponse(DjangoObjectType):
|
||||
data = GenericScalar()
|
||||
|
||||
class Meta:
|
||||
model = FeedbackResponseModel
|
||||
interfaces = (Node,)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import vbv_lernwelt.feedback.models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import vbv_lernwelt.feedback.models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("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.db import models
|
||||
|
||||
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):
|
||||
def __init__(self, **kwargs):
|
||||
|
|
@ -11,7 +14,7 @@ class FeedbackIntegerField(models.IntegerField):
|
|||
super().__init__(
|
||||
validators=validators + [MinValueValidator(1), MaxValueValidator(4)],
|
||||
null=nullable,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -37,17 +40,33 @@ class FeedbackResponse(models.Model):
|
|||
EIGHTY = 80, "80%"
|
||||
HUNDRED = 100, "100%"
|
||||
|
||||
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)
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.id:
|
||||
course_session_users = CourseSessionUser.objects.filter(
|
||||
role="EXPERT", course_session=self.course_session, expert=self.circle
|
||||
)
|
||||
for csu in course_session_users:
|
||||
NotificationService.send_information_notification(
|
||||
recipient=csu.user,
|
||||
verb=f"{_('New feedback for circle')} {self.circle.title}",
|
||||
target_url=f"/course/{self.course_session.course.slug}/cockpit/feedback/{self.circle_id}/",
|
||||
)
|
||||
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)
|
||||
course_session = models.ForeignKey("course.CourseSession", models.PROTECT)
|
||||
|
|
|
|||
|
|
@ -1,32 +1,31 @@
|
|||
import structlog
|
||||
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__)
|
||||
|
||||
|
||||
class FeedbackResponseSerializer(serializers.ModelSerializer):
|
||||
page = serializers.CharField(write_only=True)
|
||||
course_session = serializers.CharField(write_only=True)
|
||||
|
||||
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 FeedbackIntegerField(serializers.IntegerField):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
required=False, allow_null=True, min_value=1, max_value=5, **kwargs
|
||||
)
|
||||
|
||||
|
||||
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.feedback.factories import FeedbackFactory
|
||||
from vbv_lernwelt.learnpath.models import Circle
|
||||
from vbv_lernwelt.notify.models import Notification
|
||||
|
||||
|
||||
class FeedbackApiBaseTestCase(APITestCase):
|
||||
|
|
@ -50,6 +51,54 @@ class FeedbackApiBaseTestCase(APITestCase):
|
|||
|
||||
|
||||
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):
|
||||
number_basis_feedback = 5
|
||||
number_analyse_feedback = 10
|
||||
|
|
@ -159,17 +208,25 @@ class FeedbackDetailApiTestCase(FeedbackApiBaseTestCase):
|
|||
FeedbackFactory(
|
||||
circle=circle,
|
||||
course_session=csu.course_session,
|
||||
satisfaction=feedback_data["satisfaction"][i],
|
||||
goal_attainment=feedback_data["goal_attainment"][i],
|
||||
proficiency=feedback_data["proficiency"][i],
|
||||
received_materials=feedback_data["received_materials"][i],
|
||||
materials_rating=feedback_data["materials_rating"][i],
|
||||
instructor_competence=feedback_data["instructor_competence"][i],
|
||||
instructor_open_feedback=feedback_data["instructor_open_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],
|
||||
data={
|
||||
"satisfaction": feedback_data["satisfaction"][i],
|
||||
"goal_attainment": feedback_data["goal_attainment"][i],
|
||||
"proficiency": feedback_data["proficiency"][i],
|
||||
"received_materials": feedback_data["received_materials"][i],
|
||||
"materials_rating": feedback_data["materials_rating"][i],
|
||||
"instructor_competence": feedback_data["instructor_competence"][i],
|
||||
"instructor_open_feedback": feedback_data[
|
||||
"instructor_open_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()
|
||||
|
||||
response = self.client.get(
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ def get_feedback_for_circle(request, course_id, circle_id):
|
|||
course_session__course_id=course_id,
|
||||
circle__expert__user=request.user,
|
||||
circle_id=circle_id,
|
||||
)
|
||||
).order_by("created_at")
|
||||
|
||||
# I guess this is ok for the üK case
|
||||
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 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)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import vbv_lernwelt.files.utils
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0001_initial"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0002_alter_learningcontent_contents"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0003_alter_learningcontent_contents"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0004_circle_goal_description"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0005_circle_job_situation_description"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0006_alter_learningcontent_contents"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0007_alter_learningcontent_contents"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0008_alter_learningcontent_contents"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("learnpath", "0009_alter_learningcontent_contents"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("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