Merged in feature/notifications (pull request #15)
This commit is contained in:
parent
aac1c638df
commit
b5e4c30d40
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import dayjs from "dayjs";
|
||||
import { nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
|
|
@ -8,6 +9,7 @@ let i18n: any = null;
|
|||
export function setupI18n(options = { locale: "de", legacy: false }) {
|
||||
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>
|
||||
|
|
@ -137,6 +137,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 +412,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 +476,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"
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,18 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -18,6 +18,7 @@ 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)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +40,19 @@ class FeedbackResponse(models.Model):
|
|||
EIGHTY = 80, "80%"
|
||||
HUNDRED = 100, "100%"
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,125 @@
|
|||
# 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)
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import json
|
||||
from typing import List
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from vbv_lernwelt.core.models import User
|
||||
from vbv_lernwelt.core.tests.factories import UserFactory
|
||||
from vbv_lernwelt.notify.models import Notification, NotificationType
|
||||
from vbv_lernwelt.notify.service import NotificationService
|
||||
|
||||
|
||||
class TestNotificationService(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.notification_service = NotificationService
|
||||
self.notification_service._send_email = lambda a, b, c: True
|
||||
|
||||
self.admin = UserFactory(username="admin", email="admin")
|
||||
self.sender_username = "Bob"
|
||||
UserFactory(username=self.sender_username, email="bob@gmail.com")
|
||||
self.sender = User.objects.get(username=self.sender_username)
|
||||
|
||||
self.recipient_username = "Alice"
|
||||
UserFactory(username=self.recipient_username, email="alice@gmail.com")
|
||||
self.recipient = User.objects.get(username=self.recipient_username)
|
||||
self.recipient.additional_json_data["email_notification_types"] = json.dumps(
|
||||
["USER_INTERACTION", "INFORMATION"]
|
||||
)
|
||||
self.recipient.save()
|
||||
|
||||
self.client.login(username=self.recipient, password="pw")
|
||||
|
||||
def test_send_information_notification(self):
|
||||
verb = "Wartungsarbeiten: 13.12 10:00 - 13:00 Uhr"
|
||||
target_url = "https://www.vbv.ch"
|
||||
self.notification_service.send_information_notification(
|
||||
recipient=self.recipient,
|
||||
verb=verb,
|
||||
target_url=target_url,
|
||||
)
|
||||
|
||||
notifications: List[Notification] = Notification.objects.all()
|
||||
self.assertEqual(1, len(notifications))
|
||||
notification = notifications[0]
|
||||
self.assertEqual(self.admin, notification.actor)
|
||||
self.assertEqual(verb, notification.verb)
|
||||
self.assertEqual(target_url, notification.target_url)
|
||||
self.assertEqual(
|
||||
str(NotificationType.INFORMATION), notification.notification_type
|
||||
)
|
||||
self.assertTrue(notification.emailed)
|
||||
|
||||
def test_send_progress_notification(self):
|
||||
verb = "Super Fortschritt! Melde dich jetzt an."
|
||||
target_url = "https://www.vbv.ch"
|
||||
course = "Versicherungsvermittler/in"
|
||||
self.notification_service.send_progress_notification(
|
||||
recipient=self.recipient, verb=verb, target_url=target_url, course=course
|
||||
)
|
||||
|
||||
notifications: List[Notification] = Notification.objects.all()
|
||||
self.assertEqual(1, len(notifications))
|
||||
notification = notifications[0]
|
||||
self.assertEqual(self.admin, notification.actor)
|
||||
self.assertEqual(verb, notification.verb)
|
||||
self.assertEqual(target_url, notification.target_url)
|
||||
self.assertEqual(course, notification.course)
|
||||
self.assertEqual(str(NotificationType.PROGRESS), notification.notification_type)
|
||||
self.assertFalse(notification.emailed)
|
||||
|
||||
def test_send_user_interaction_notification(self):
|
||||
verb = "Anne hat deinen Auftrag bewertet"
|
||||
target_url = "https://www.vbv.ch"
|
||||
course = "Versicherungsvermittler/in"
|
||||
self.notification_service.send_user_interaction_notification(
|
||||
sender=self.sender,
|
||||
recipient=self.recipient,
|
||||
verb=verb,
|
||||
target_url=target_url,
|
||||
course=course,
|
||||
)
|
||||
|
||||
notifications: List[Notification] = Notification.objects.all()
|
||||
self.assertEqual(1, len(notifications))
|
||||
notification = notifications[0]
|
||||
self.assertEqual(self.sender, notification.actor)
|
||||
self.assertEqual(verb, notification.verb)
|
||||
self.assertEqual(target_url, notification.target_url)
|
||||
self.assertEqual(course, notification.course)
|
||||
self.assertEqual(
|
||||
str(NotificationType.USER_INTERACTION), notification.notification_type
|
||||
)
|
||||
self.assertTrue(notification.emailed)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
@api_view(["POST", "GET"])
|
||||
def email_notification_settings(request):
|
||||
EMAIL_NOTIFICATION_TYPES = "email_notification_types"
|
||||
if request.method == "POST":
|
||||
request.user.additional_json_data[EMAIL_NOTIFICATION_TYPES] = request.data
|
||||
request.user.save()
|
||||
return Response(
|
||||
status=200,
|
||||
data=request.user.additional_json_data.get(EMAIL_NOTIFICATION_TYPES, []),
|
||||
)
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<svg viewBox="0 0 21 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.95081 19.8397H6.92941C7.0088 21.0615 7.70572 22.1554 8.77756 22.7464C9.8494 23.3331 11.1506 23.3331 12.2224 22.7464C13.2943 22.1598 13.9912 21.0615 14.0706 19.8397H18.0492C18.5564 19.8397 19.0416 19.6456 19.4033 19.2927C19.765 18.9399 19.9811 18.4635 19.9988 17.9562C20.0164 17.449 19.84 16.9594 19.5004 16.5845L17.7493 14.6349V11.0665C17.7448 9.62853 17.317 8.22588 16.5142 7.03054C15.7114 5.8396 14.5734 4.90891 13.2458 4.36196V3.63858C13.2546 2.93284 12.9943 2.24916 12.5224 1.72868C12.046 1.20378 11.3932 0.881792 10.6919 0.820039C9.92879 0.767109 9.18336 1.03617 8.62759 1.56106C8.07182 2.08155 7.75865 2.80493 7.75424 3.56801V4.3752C6.42657 4.92214 5.28856 5.84842 4.4902 7.03936C3.68742 8.23029 3.25957 9.63294 3.25516 11.0709V14.6349L1.49963 16.5801C1.16 16.955 0.983562 17.449 1.00121 17.9562C1.01885 18.4635 1.23057 18.9399 1.59667 19.2927C1.95836 19.6412 2.44797 19.8397 2.95081 19.8397ZM10.4978 22.1863C9.85381 22.1863 9.23188 21.9437 8.7555 21.5114C8.27913 21.0791 7.97919 20.4837 7.91744 19.8397H13.0781C13.0164 20.4793 12.7165 21.0747 12.2401 21.5114C11.7637 21.9437 11.1418 22.1819 10.4978 22.1863ZM17.0303 15.3141L18.7593 17.2417C18.927 17.4269 19.0152 17.6695 19.0063 17.921C18.9975 18.1724 18.8917 18.4062 18.7108 18.5782C18.53 18.7502 18.2918 18.8472 18.0404 18.8472H2.95081C2.69939 18.8472 2.4612 18.7502 2.28036 18.5782C2.09951 18.4062 1.99365 18.168 1.98483 17.921C1.97601 17.6695 2.06422 17.4269 2.23184 17.2417L4.11086 15.1509C4.19026 15.0583 4.23878 14.9436 4.23878 14.8201V11.0665C4.24319 9.77409 4.64458 8.51259 5.39001 7.45839C6.13545 6.40419 7.19406 5.60142 8.41146 5.16915C8.60995 5.09858 8.73786 4.91332 8.73786 4.70601V3.55919C8.73786 3.09163 8.92312 2.64614 9.25393 2.31532C9.58474 1.98451 10.0302 1.79925 10.4978 1.79925H10.6169C11.0712 1.84336 11.4902 2.05508 11.7946 2.39472C12.0989 2.73435 12.2621 3.17544 12.2533 3.63417V4.69719C12.2533 4.9045 12.3856 5.08976 12.5797 5.16033C13.7971 5.59259 14.8513 6.39096 15.6012 7.44516C16.3466 8.49935 16.7524 9.76086 16.7568 11.0532V14.3217"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M24.64,19.76l-1.94-2.2v-3.46c0-3.06-1.83-5.82-4.61-7.04v-.76c0-1.7-1.38-3.08-3.08-3.08s-3.08,1.38-3.08,3.08v.76c-2.78,1.22-4.61,3.98-4.61,7.04v3.46l-1.94,2.2c-.62,.69-.76,1.65-.38,2.5s1.19,1.37,2.12,1.37h3.81c.49,1.81,2.13,3.15,4.09,3.15s3.6-1.34,4.09-3.15h3.81c.93,0,1.74-.53,2.12-1.37s.24-1.8-.38-2.5Zm-9.64,5.52c-1.13,0-2.09-.68-2.52-1.65h5.03c-.42,.97-1.39,1.65-2.52,1.65Zm8.65-3.64c-.05,.11-.26,.49-.75,.49H7.1c-.5,0-.7-.37-.75-.49-.05-.11-.19-.52,.13-.89l2.13-2.41c.12-.14,.19-.31,.19-.5v-3.75c0-2.61,1.65-4.95,4.11-5.83,.3-.11,.5-.39,.5-.71v-1.27c0-.87,.71-1.58,1.58-1.58s1.58,.71,1.58,1.58v1.27c0,.32,.2,.6,.5,.71,2.46,.88,4.11,3.22,4.11,5.83v3.75c0,.18,.07,.36,.19,.5l2.13,2.41c.33,.37,.19,.77,.13,.89Z"/></svg>
|
||||
|
After Width: | Height: | Size: 834 B |
Loading…
Reference in New Issue